HomeAboutPostsTagsProjectsRSS
┌─
ARTICLE
─┐

└─
─┘

我的 Obsidian 知识库靠一个"AI 守门员" ( Obsidian Gatekeeper )打理:新笔记自动分类、打标签、提炼概念( High-Order Notes / HON ),以及做语义检索。这套系统最初跑在一台远程 Mac mini 上——Node.js 服务 + better-sqlite3 + sqlite-vec 向量扩展,手机端每次整理都要跨网络调它。

好用,但有代价。后来我把整条链路搬进了 iPhone 上的 Open Minis——一个内置 iSH ( Alpine Linux )终端环境的 AI 助手 App :原生编译向量扩展、用纯 Python 标准库写检索工具、把守门员规则注册进 Agent 技能系统让新会话也能自动识别。本文记录这次迁移的完整路径,分三步走,你可以在自己的设备上照着复现。

为什么迁:远程守门员的三个痛点

旧架构的核心问题,一句话概括:知识库的日常操作绑在了一台你不一定带在身边的服务器上。具体拆开是三点:

  1. 网络与设备依赖:离线或弱网(出行、飞机上)守门员直接断连;
  2. 运维成本 : Mac mini 的守护进程、端口转发和 SSH 凭据维护,每一项都是持续要管的负担;
  3. 响应延迟:每次手机端发起整理或检索,都要跨网络走一趟 RPC 或 SSH 。

迁移目标很明确:Local-First——笔记整理、向量计算、数据库查询全部在 iOS 本地完成,不再依赖任何远程基础设施。

新旧架构对比

[ 旧架构 (远程) ]
iOS (Obsidian / Agent) ---> [ SSH / Network ] ---> Mac mini (Node.js + better-sqlite3 + sqlite-vec)

[ 新架构 (iOS 本地原生) ]
iOS (Open Minis / iSH Shell)
├── Local Vault Mount (/var/minis/mounts/onote-neo-main)
├── Persistent Shared (/var/minis/shared/ -> vault.db + vec0.so)
└── Skill Protocol (/var/minis/skills/obsidian-gatekeeper/)

变的是服务端的位置:从"远程 Mac mini"换成"Open Minis 内置的本地 iSH 沙盒" ;不变的是数据层: SQLite + 向量检索这套逻辑原样保留,只是换了宿主。

前置条件

动手之前需要准备齐这些:

  • 一台 iOS 设备,装好 Open Minis ——App 内置 iSH ( Alpine Linux )终端环境,向量扩展、 Python 工具都跑在它里面;
  • Obsidian vault 通过 iOS 「文件」 App 挂载到 /var/minis/mounts/ 下;
  • 一个 Google Cloud 项目,开通 Vertex AI 上的 Gemini Embedding API ,用 ADC ( Application Default Credentials )拿到 OAuth 凭据(user-adc.json);
  • 知识库的向量数据已生成:笔记的 embedding 存在 vault.dbvec_notes 表里,随数据库文件一起迁移过来。

第一步:在 Open Minis 的终端里原生编译 sqlite-vec

问题sqlite-vec 官方 Release 没有 aarch64-unknown-linux-musl 的预编译包——这正是 iOS 上 Alpine Linux 的目标平台。装不上现成的,只能本地编译。

做法:装好工具链,用 clang 直接把 C 源码编成共享库:

# 安装基础编译环境
apk add gcc musl-dev clang sqlite-dev

# 下载 sqlite-vec 源码并编译为动态库 vec0.so
clang -fPIC -shared -O3 \
  -D_GNU_SOURCE \
  sqlite-vec.c \
  -o /var/minis/shared/vec0.so

关键点 : musl 和 glibc 的头文件定义有差异,编译过程中需要对 musl 做针对性微调。产物是一个原生 vec0.so 动态链接库,之后由 Python 的 sqlite3 模块直接 load_extension 加载——移动端本地执行的 C 向量扩展,没有中间层。

第二步:用纯 Python 标准库写零依赖 RAG 引擎

问题 : Node.js 及 npm 的依赖树太重,不想在手机沙盒里维护一套 node_modules

做法 : Python 3 标准库 + 内置 sqlite3 写一个轻量 CLI 工具 vault_tool.py,只做三件事:

  1. 动态加载向量扩展:直接加载上一步编译好的 vec0.so
  2. 凭据与 Embeddings 接入:用 Google Cloud ADC OAuth 2.0 凭据调用 gemini-embedding-2 ( 3072 维向量);
  3. KNN 语义检索:在 SQLite 内直接执行向量距离矩阵计算。
import sqlite3
import json
import urllib.request

# 1. 初始化 SQLite 数据库并加载本地 C 向量扩展
def get_db_connection():
    conn = sqlite3.connect('/var/minis/shared/vault.db')
    conn.enable_load_extension(True)
    conn.load_extension('/var/minis/shared/vec0.so')
    return conn

# 2. 调用 Gemini Embedding API 生成 3072 维向量
def get_embedding(text, access_token):
    url = "https://europe-west1-aiplatform.googleapis.com/v1/projects/afk-blog/locations/eu/publishers/google/models/gemini-embedding-2:predict"
    req = urllib.request.Request(
        url,
        data=json.dumps({"instances": [{"content": text}]}).encode('utf-8'),
        headers={
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json"
        }
    )
    with urllib.request.urlopen(req) as resp:
        res = json.loads(resp.read().decode('utf-8'))
        return res['predictions'][0]['embedding']['values']

# 3. 在 SQLite 中执行向量 KNN 相似度检索
def search_similar_notes(query_vector, limit=5):
    conn = get_db_connection()
    cursor = conn.cursor()

    # 使用 sqlite-vec 提供的 vec_distance_cosine 函数
    query = """
    SELECT rowid, vec_distance_cosine(embedding, ?) as distance
    FROM vec_notes
    ORDER BY distance ASC
    LIMIT ?
    """
    cursor.execute(query, (json.dumps(query_vector), limit))
    return cursor.fetchall()

三个函数的职责边界很干净:get_db_connection 是纯本地的(数据库和扩展都在设备上);get_embedding 是整条链路里唯一联网的一步,把查询文本变成 3072 维向量;search_similar_notes 回到本地,用余弦距离排序取 top-k 。全部依赖只有 Python 标准库 + 一个 C 扩展,零第三方包。

第三步:把守门员注册进 Agent 技能系统

问题:手机端的 AI 助手( Open Minis )每次新开对话都是一个全新上下文,怎么保证它依然认识 vault 的守门员规则、知道去哪找数据库?

做法:跨会话持久化 + 显式的文件系统划分。

  1. Skill 自动化挂载:把守门员协议写成 SKILL.md,放进 /var/minis/skills/obsidian-gatekeeper/ 。每次启动新会话, Open Minis 系统会自动扫描并注册该技能,规则随会话常驻。
  2. 共享文件系统划分
    • /var/minis/mounts/onote-neo-main/:通过 iOS 「文件」挂载的本地 Obsidian 知识库;
    • /var/minis/shared/:持久化存放 vault.db(向量数据库)、vec0.so ( C 动态库)、user-adc.json(认证凭据)和 vault_tool.py

这样 Agent 在任何新会话里都能定位工具链和规则,跨会话、跨重启都有效。

实测效果

迁移完成后的日常体验:

  • 秒级响应:本地扫描 vault 里数百篇笔记及 HON 概念节点,没有网络传输等待;
  • 无服务器依赖 : Mac mini 挂掉、 IP 变动、 SSH 断连——这些曾经的真实事故,现在都与我无关了;
  • 无缝交互:在手机上随口一句"帮我把这段想法归档并检查是否有重复的 HON 笔记" , Agent 自动执行语义向量搜索、计算余弦相似度,直接更新挂载的 md 文件。

边界:说说"完全本地"到底本地在哪

这里需要诚实一点。迁移去掉了 Mac mini ,但整条链路里 仍有一环必须联网 : embedding 由 Google 的 gemini-embedding-2 API 生成(就是第二步的 get_embedding)。真正 100% 本地的是:

  • 向量检索 : sqlite-vec 的 KNN 计算,全本地执行;
  • 文件读写:挂载的 vault 直接操作,不走网络;
  • 规则与协议 : SKILL.md 技能注册,本地常驻。

所以准确的说法是:** 基础设施完全本地化, embedding 生成用的是云端 API**。需要说明的是,向量生成这一步本地化完全可行——在移动端跑一个量化的本地 embedding 模型(比如小型 sentence-encoder ),就能把最后这一环也收回来,实现真正完全离线。我只是目前选择了 Google 的云端模型,没有做本地化。这是取舍,不是技术限制。

结语

这次迁移最大的收获,不是"在手机上跑通了 RAG",而是验证了一个可以复用的极简组合:

C 动态库(原生性能)+ Python 标准库(零依赖)+ 本地文件挂载(数据就近)+ 结构化 Skill 协议( Agent 可发现)

移动端的 Linux 沙盒早已不是玩具: Open Minis 内置的 iSH 沙盒里的 Alpine ,足够原生编译 C 扩展、跑完整的 SQLite 、支撑一个每天都在用的 AI 工作流。去掉远程中间件之后,架构变简单了,系统反而更可靠——这大概就是 Local-First 的真正收益。

┌─
ARTICLE
─┐

└─
─┘

网上一直流传一个说法:动态语言比静态语言更适合 LLM 写代码,因为省略类型声明让代码更紧凑、更省 token 。最常被引用的数据是 Alderson 的一个 eval : Clojure 平均 109 tokens 就能解一道题, J 只要 70 ,而 C 要 2.6 倍。 Google 的 AI 摘要甚至直接把这个结论当作常识输出。

我一开始也信了。直到认真读了 Dan Luu 的反驳研究 ,再对照自己用 elle-lisp 、 emacs-lisp 给 LLM 写代码的亲身经验,才发现这个结论远没有表面那么干净。

玩具题陷阱: 70 个 token 能写完的题,不是题

Dan Luu 指出了流传 eval 的根本问题:题目太 trivial 了。一个 J 用 70 tokens 、 Clojure 用 109 tokens 就能解完的题,本质是"打印一个答案",不是编程。他引用了自己之前研究"caveman mode"的教训:在 trivial 任务上成立的巨大优势,一旦任务需要一点真正的"工作",优势就消失了

第二个流传 eval 更离谱:测试本身执行了错误的路径(路径不存在),一个 agent 用符号链接把评测导向了自己的可执行文件,导致后面所有语言的评分都串了。结论建立在坏掉的评测上。

所以 Dan Luu 自己预注册了三个猜测,然后跑了两组真实 eval :实现 zstd 解码器(读 RFC 写代码),以及移植 Pandoc ( TDD 式、用 holdout 测试评分)。

真实数据:动态静态打平,冷门语言垫底

两个 eval 的结果(我逐点提取了他交互图表里的数据):

zstd eval(成本 $3-10 ,正确率约 22-30%):

  • Python 和 JavaScript 是最优组合:最便宜 + 最高正确率
  • C 、 F# 紧随其后,成本低、正确率中上
  • Go 稳居中上(~$3.9 ,正确率 ~27.6%)
  • Clojure 垫底 : 36/40 个 medium 程序挂在同一个 byte 转换 bug 上( 128–255 抛异常)

Pandoc eval(成本 $495-1550 , holdout 通过率 9-32%):

  • PHP 、 F# 、 C# 、 Elixir 挤在左上角:便宜 + 高分
  • Python 分数高但成本接近翻倍
  • Go 依然稳定中上(~28%,成本还低)
  • 冷门语言( J 、 Factor 、汇编、 Zig)全部右下角

结论很清楚:

  1. 动态 vs 静态:平手 。 medium effort 下动态略好, ultra effort 下结果混杂、静态语言反而有几个最好的。
  2. “怪异语言"霸权不成立 。 AI lab 不会为冷门语言投入 RL 训练数据,模型没见过就是没见过。
  3. 流行度是最强的单变量。越流行的语言,生成结果越便宜、越正确——这是两个 eval 里唯一稳定的规律。
  4. 别对任何单一语言下结论。每个失败都是 idiosyncratic 的( Clojure 的 byte bug 、 Rust 的 cargo 调用循环),任务一换结果就变。

Go 的迷思:为什么"Go 适合 LLM"流传最广

顺带回答一个常见问题。 Go 在这份数据里既不差也不顶尖:两榜稳定中上。但"Go 适合 LLM 生成"的迷思有真实的机制基础:

  • 流行度 : GitHub 常年前五,训练语料充足,正好踩中 Dan Luu 发现的最强变量。
  • 风格统一gofmt 强制格式、惯用法单一(“one way of doing things” )。 Ryan Brewer 在 《Principles of Simple Programming Languages》 里论证过: LLM 是"美学引擎”,风格永远一致的代码,预测就更准。

迷思没错,只是被夸大了——Go 是"可靠的中庸",不是冠军。

但是:我的 Lisp 体验完全不支持"冷门语言不行"

到这里,数据说冷门语言垫底,但我的亲身经验是反例:我用 elle-lisp (一个几乎没有任何公开语料的类 Janet 语言)和 emacs-lisp 配合 Claude/Codex 写真实项目——包括让 Claude 修复 elle 编译器的 SSA bug ( 1970 个测试通过)、写 tree-sitter 语法、做业务系统。冷门 Lisp 的表现相当不错

为什么?我后来想明白四个机制:

  1. S 表达式是极端的语法先验 。 Lisp 的全部语法规则几乎只有一条:(func args...) + 括号配对。模型不需要见过 elle-lisp ,只要见过任何 Lisp ,结构模式全部迁移。对比 Python 的多种写法( for/列表推导/map/lambda ), Lisp 的结构是全局唯一的。
  2. 跨方言迁移池 。 elle-lisp 语料≈0 ,但 Common Lisp + Scheme + Clojure + elisp + Janet 共享同一核心,加起来是个可观的池子。
  3. elisp 其实不算冷门 。 GitHub 上海量的 .emacs.d 配置就是语料。
  4. 任务结构匹配。我让 LLM 干的是编译器修复、语法解析、业务逻辑——结构密集型任务,这类代码在模型语料里无论什么语言都极其丰富。

所以和 Dan Luu 的数据不矛盾,是任务域错位:他测的是 zstd 、 Pandoc 这种生态密集型任务(需要库、字节操作、具体语言的陷阱知识),我做的是结构密集型任务。 Lisp 的 LLM 友好度是强任务依赖的。

但也要诚实:存在"幻影掌握"风险 。 Clojure 的 byte bug 说明模型能写出看起来非常地道的 Lisp ,然后错掉 ——极简语法让模型"流畅地犯错"。我的 elle-lisp 体验好,部分原因是编译器项目测试极其完备,把幻影错误都挡掉了。

宏的插曲:为什么 LLM 从不主动提议写宏

一个有意思的观察:我用了很长时间 elle-lisp , Claude/Codex 从来没有主动提议过写宏。分析下来有三层原因:

  1. 宏在训练分布里是长尾。模型输出永远是概率最高的路径,宏是分布里的尾巴。
  2. 模型没有全局视野。写宏的动机是"我看到 10 处重复"——这是跨生成步骤的观察,而 LLM 每次只生成一小段,永远看不见那 10 处。
  3. 协作分工的天然边界 :人识别模式, LLM 执行。我在 commonplane 里找到 40 处"函数定义 + dispatch 表注册"的双重注册——这是宏的教科书场景,但项目刻意用数据表 + 测试兜底替代了宏。不是没有场景,是场景被设计掉了。这本身说明代码库风格会塑造 LLM 的行为。

一个综合模型

把 Brewer 的理论和 Dan Luu 的数据合起来,我得到一个可用的判断框架:

语言对 LLM 的友好度 ≈ 语料规模 × 风格一致性

  • Python :语料王 + 风格一般 → 综合第一
  • Go :两者都不错 → 稳定中上
  • Lisp :语料中低但有迁移池 + 风格极统一 → 结构任务极强、生态任务翻车

这对我自己设计语言也有启示。我笔记里的 FlowLang 设计假设——统一简单语法、无长距离依赖、流水线风格能帮助 LLM 预测——在机制上是对的(风格一致性确实有用),但数据提醒我:风格一致性是二阶效应,语料规模才是一阶。为 LLM 设计语言,光靠语法优雅不够,还得考虑怎么让语料积累起来。

结论

如果你在选"LLM 主力生成语言",我的实操建议:

  1. 流行度优先,简单性加分 。 Python 是安全冠军; F# 这种语料充足 + 类型安全的是黑马; Go 是永远不垫底的安全牌。
  2. 任务决定语言 。结构密集型任务(编译器、算法、业务逻辑), Lisp 系完全可用,我的经验就是证据;字节级、生态密集型任务,老实切主流语言。
  3. 别被单点 eval 绑架 。 Dan Luu 自己反复强调:两个 eval 不足以对任何语言下结论。换语言的收益,远小于修 prompt 和 harness 的收益。

最后留一个问题给你:如果语料规模真的是一阶变量,那"为 LLM 设计新语言"这件事的 ROI 到底在哪?我的 FlowLang 笔记还在,但这个问题我至今没想透。

┌─
ARTICLE
─┐

└─
─┘

Emacs has an unusually flexible UI, but most of that flexibility still lives inside a rigid layout model. Buffers, windows, side windows, mode lines, minibuffers, and popups all compete for the same rectangular grid. Everything you see is, in the end, a region of that grid.

I wanted to try a different shape. Not another buffer fighting for space, but a small floating HUD pinned to the Emacs frame, sitting outside the normal layout entirely, with a modern visual style and a live data feed coming from Emacs Lisp.

The goal was never to replace Emacs buffers. It was to give Emacs a new kind of surface for information that should be glanceable, persistent, and visually compact: the sort of thing you want hovering in a corner, not occupying a window split.

The HUD Idea

The first concrete target was a workspace HUD: a card in the corner of the frame showing project status, git state, and whatever context is relevant to the buffer I happen to be editing.

That sounds simple, but it pushes against a handful of Emacs defaults. A normal buffer participates in the window layout, so it takes up space you have to manage. A popup tends to be transient and focus-sensitive, so it disappears the moment you look away. A mode line is wonderfully compact but visually boxed in. A child frame can genuinely float, but it asks for careful handling around sizing, positioning, focus, and cleanup.

What I wanted was closer to a small native overlay than to any of these: Emacs would still own all of the editor state, and the HUD would do nothing but render the state it was handed.

Getting there meant trying a few approaches and discarding most of them. The path below is roughly the order I worked through them.

Spike 1: A Text Child Frame

The first experiment was the most boring one on purpose: a plain Emacs child frame holding a text buffer.

It was boring, but it was also the experiment that proved the windowing idea was sound. A child frame can be parent-relative, undecorated, and non-focusable. It can be repositioned when the parent frame moves, and it can be kept out of the normal window-split layout. In other words, it can behave like an overlay rather than a window.

That was enough to confirm the HUD could exist as a stable surface. It was not enough for the look I was after. Text rendered into a buffer still reads as a clever mode line, not as a modern panel, and no amount of careful formatting was going to change that.

Spike 2: SVG in a Child Frame

So the next step was to render the card as SVG instead of text.

Visually, this got much closer to the target: rounded panels, custom spacing, real vector shapes, theme-aware colors. For a while it felt like the answer. Then the portability problem showed up.

SVG support in Emacs depends heavily on how Emacs was built. With solid librsvg support the result can look great, but on some macOS builds native SVG rendering is far more limited, and details like filters and CSS may not behave the same way from one machine to the next. SVG turned out to be a great design probe and a poor foundation. I could prototype the look, but I couldn’t depend on it.

Spike 3: A Native WebView

If I wanted a real rendering canvas, the obvious move was a real web view, so the third experiment reached for a native WebKit view through Appine.

The rendering model was genuinely attractive. A web view hands you a full browser canvas, and that opens the door to richer UI toolkits than anything Emacs renders natively. The trouble was lifecycle and composition rather than rendering. A persistent HUD has to coexist quietly with other web views and with ordinary Emacs use, and the native WebView wanted too much ownership over the viewport to do that gracefully. It was a promising route for an active web panel, but an awkward one for a background HUD that’s meant to stay out of the way.

Spike 4: xwidget-webkit

The fourth experiment used Emacs’ own built-in xwidget-webkit, and this was the first version where every piece fit together at once.

xwidget-webkit can host a browser surface directly inside Emacs. That surface can live inside a child frame, multiple WebKit sessions can coexist without stepping on each other, and the page itself can run WebAssembly. Best of all, Emacs Lisp can reach into the page and push data with xwidget-webkit-execute-script. That last point is what made the whole HUD idea practical: Emacs stays in charge, and the page just listens.

There were still some Emacs-specific rough edges to sand down. xwidget-webkit-new-session behaves like an interactive command and will happily disturb the user’s window layout, so the implementation saves and restores window configurations around session creation. The xwidget buffer also needs to be stripped of everything that marks it as a buffer — no mode line, no header line, no fringes, no line numbers — and tearing it down has to bypass the usual xwidget kill confirmation. None of that is hard once you know it’s there, and with those details handled, xwidget-webkit became a dependable host for the HUD.

The Current Architecture

The architecture that came out of these spikes is intentionally small, and the boundary it draws is the whole point: Emacs owns state, timing, and editor integration, while egui owns layout and drawing. Nothing crosses that line except JSON.

flowchart TD
    subgraph emacs["Emacs"]
        app["Application code
workspace-hud.el"] panel["egui-panel.el"] server["Local asset server
make-network-process"] frame["Undecorated child frame"] end subgraph webkit["xwidget-webkit session"] html["index.html shell
hudPushState / hudPushTheme"] wasm["egui / WASM renderer"] end app -->|"collect git + project state, as JSON"| panel panel -->|"hosts"| frame panel -->|"xwidget-webkit-execute-script"| html server -->|"serve index.html + pkg/* over 127.0.0.1"| html frame -->|"loads"| html html -->|"replace state, request repaint"| wasm wasm -->|"paints the card into"| frame

On the Emacs side, egui-panel.el does the heavy lifting. It starts a tiny local HTTP server with make-network-process, serves index.html and the generated WASM bundle from 127.0.0.1, creates the undecorated child frame, loads xwidget-webkit inside it, and pushes theme and state as JSON.

The local server is the one piece that looks like overkill until you hit the wall that requires it: WebKit refuses to instantiate WebAssembly from a file:// origin. Keeping a small server inside Emacs sidesteps that without dragging in npm, a global web server, or a separate daemon.

The renderer itself is an egui app compiled to WebAssembly. Its HTML shell exposes just two entry points:

window.hudPushState(json)
window.hudPushTheme(json)

Emacs calls those through xwidget-webkit-execute-script, and on the other side the WASM app swaps in the new state and asks egui to repaint. With that bridge in place, the workspace HUD demo collapses into a thin data source. A buffer, window, or save event triggers Emacs to gather project and git state, serialize it to JSON, hand it to xwidget-webkit, and let egui repaint:

buffer/window/save event
  -> collect project and git state in Emacs Lisp
  -> JSON payload
  -> xwidget-webkit
  -> egui repaint

Because the contract is just JSON in one direction, each side stays replaceable. Emacs doesn’t know how the card is drawn, and the renderer doesn’t know where the data came from.

Why This Feels Promising

The interesting result isn’t simply that the HUD works. It’s that Emacs gains a new UI surface without giving up any of the things that make Emacs worth using in the first place.

The editor stays Emacs Lisp-driven. The renderer is replaceable. And because the HUD lives outside the window layout, it never steals a split or forces itself into the buffer model — it just floats there, showing what Emacs tells it to show.

There’s still plenty left to explore: click-back from the HUD into Emacs commands, multiple panel roles, richer theming, and a tighter workspace design. But the core shape is settled, and it’s a short one:

Emacs Lisp state -> JSON -> xwidget-webkit -> egui/WASM HUD

For an idea that started as nothing more than “what if Emacs had a modern floating HUD?”, that’s a good place to have landed.

The project is available at GitHub - nohzafk/emacs-workspace-hud: A floating workspace status HUD for Emacs, showing Git, LSP, and diagnostic state in a WebAssembly-powered egui card. · GitHub , and it’s also extensible to add more sections to the HUD.

┌─
ARTICLE
─┐

└─
─┘

I’m building a real-time Mermaid preview for Markdown in Emacs. The idea is straightforward: grab a fenced Mermaid block, pipe it through mmdflux, get SVG back, and display it inline in the buffer.

It almost worked on the first try. Nodes rendered. Labels rendered. Edges rendered. But the arrowheads were gone.

The Broken Diagram

This block should obviously have arrows:

flowchart LR
    Decls["Declarations
package! · config-unit!"] Elle["Elle backend"] Runtime["Runtime helpers
package-vc · unit exec · reload"] Decls -->|export session data| Elle Elle -->|emit forms via :eval| Runtime

I got lines connecting the nodes, but no arrowheads. A flowchart without arrows is just boxes and string.

The first debugging question writes itself:

Is mmdflux emitting bad SVG, or is Emacs failing to render valid SVG?

Checking the SVG

mmdflux supports text, SVG, and structured JSON output. I was using SVG.

Mermaid-style arrows are represented with <marker> definitions and marker-end references — the standard SVG mechanism for drawing arrowheads at the final vertex of a path. Nothing exotic.

I reduced the problem to a minimal SVG:

<svg xmlns="http://www.w3.org/2000/svg" width="220" height="80">
  <defs>
    <marker id="arrow"
            viewBox="0 0 10 10"
            refX="10" refY="5"
            markerWidth="8" markerHeight="8"
            orient="auto">
      <path d="M 0 0 L 10 5 L 0 10 z" fill="black"/>
    </marker>
  </defs>
  <path d="M 20 40 L 180 40"
        stroke="black" stroke-width="4" fill="none"
        marker-end="url(#arrow)"/>
</svg>

Then rendered it outside Emacs:

resvg arrow.svg arrow-resvg.png
sips -s format png arrow.svg --out arrow-sips.png

resvg drew the arrowhead. sips (Apple’s renderer) did not.

That was the answer. The SVG was fine. The rendering backend my Emacs build was using doesn’t support SVG markers.

The Emacs Build Detail

My custom macOS Emacs build uses the native image API:

--with-native-image-api

So Emacs happily reports SVG support:

(image-type-available-p 'svg)
;; => t

But t here only means “I can load an SVG and put pixels on screen.” It says nothing about feature coverage. The native macOS image API doesn’t implement the full SVG spec — and <marker> is one of the gaps.

The proper solution is librsvg, which is what emacs-plus builds with by default and what the Emacs manual associates with SVG support. If you’re using a stock Homebrew Emacs build, you probably already have it and will never hit this.

Why I Didn’t Just Add librsvg

Because my Emacs build project, ebuild , has a strong constraint: the final binary should be static and self-contained.

Pulling librsvg from Homebrew would work, but it drags in a dynamic dependency stack. The whole point of the build is a single, mostly-static artifact — adding a runtime link against Homebrew’s library tree defeats that.

Building librsvg from source is the other option, and it’s not small. You’re taking on Rust/Cargo, cargo-cbuild, Meson, Cairo (with PNG support), FreeType, GLib, libxml 2, and Pango — with optional deps for GDK-Pixbuf, GObject introspection, Vala bindings, AVIF support, and more. Upstream also notes that reproducible builds need vendored Cargo dependencies, since Cargo wants to fetch crates at build time.

That’s not “add a library.” That’s importing a slice of the GNOME graphics stack into my build system. A much bigger project than fixing Mermaid preview arrows.

So I chose a workaround.

The Workaround: Rasterize Before Display

The pipeline becomes:

Mermaid source → mmdflux → SVG → resvg → PNG → Emacs buffer

SVG stays as the interchange format — mmdflux already produces good SVG, and I don’t want to lose that. But before handing it to Emacs, I rasterize with resvg:

resvg diagram.svg diagram.png

Then display the PNG:

(create-image png-file 'png nil
              :ascent 'center
              :scale 1
              :max-width max-width
              :max-height max-height)

This sidesteps the broken marker rendering entirely.

Why PNG Is a Workaround, Not a Fix

A proper SVG renderer inside Emacs is the right answer. With librsvg, the preview stays vector-based, scales cleanly, and doesn’t need an intermediate rasterization step.

But for now, PNG is the right tradeoff:

  • Arrowheads render correctly.
  • No Homebrew librsvg linked into the final binary.
  • No vendored GNOME dependency chain to maintain.
  • mmdflux stays unchanged.

The bug wasn’t in Mermaid. It wasn’t in mmdflux. It was in the SVG rendering path of my Emacs build — a gap in feature coverage that only shows up when you hit the specific SVG features Mermaid relies on.

The practical fix: move the final rendering step to a tool that actually implements the spec.

valid SVG + capable rasterizer = correct preview

Sources

┌─
ARTICLE
─┐

└─
─┘

I like to read agent output, but the default style talks too much. Every response starts with an apology or a promise. “I’d be happy to help,” “let me take a look,” “great question.” None of that moves me forward. I skip it every time. So I wanted something brief.

But pure brevity has its own problem, the agent moves faster than I read. If it compresses everything, I lose the thread. I stop understanding what just happened and why. Then I have to scroll back, re-read, reconstruct. That costs more time than the verbose version did.

The real bottleneck isn’t the agent’s output length. It’s whether my mental model can keep up with what the agent is doing.

That realization changed how I thought about the problem. It’s not about making everything short. It’s about knowing what deserves clarity and what gets compressed. The agent should spend attention on the essential signal, the thing I need to understand to make the next decision or stay oriented and collapse everything else to almost nothing.

I found that principle already existed in ops room communication doctrine. The operator doesn’t relay everything to the commander. They filter. The test is simple: does this change what happens next, or does it keep the commander’s situational awareness accurate enough for the decision after that? If neither, it doesn’t transmit.

That’s what I wanted from a coding agent. Not a butler, not a caveman. An ops room operator: brief by default, precise when it matters, always keeping me oriented enough to stay in the loop.

---
name: Ops Room
description: Brief by default, signal when it matters — keeps human mental model in sync with agent
keep-coding-instructions: true
---

Brief by default. Signal when it matters.

## Core Principle

The agent moves faster than the human reads. The job is not to document
everything — it is to move the human's mental model forward at each step.

Compress noise. Surface signal. Keep the human oriented.

## Voice

- Short sentences. Direct. Present tense.
- No preamble: no "let me", "I'll help you", "great question", "certainly".
- No apologies. No hedging. No restating the request.
- State findings and decisions directly.

## Orient Before Acting

One line of intent before any significant change. Not an explanation — an
anchor so the human knows what is about to happen.

- "Removing dead function in utils.py."
- "Splitting auth into two files — logic was mixed with routing."
- "Null check missing on line 42. Fixing."

Skip it for trivial or obviously-implied steps.

## Signal vs Noise

At each step, identify what the human *must* understand to stay oriented.
Give that part clarity. Compress or drop everything else.

**The test:** signal = changes the next action, OR keeps situational awareness
accurate enough to make the decision after that. Everything else is noise
regardless of how true or interesting it is.

**Signal — give it space:**
- What was found and why, in one clause — so the human can reconstruct what happened
- A non-obvious choice and the one-line reason
- A risk or side-effect the human needs to know before proceeding
- The next decision point, if it belongs to the human

**Noise — compress or skip:**
- Routine steps that match the request exactly
- Status confirmations once is enough ("Done.")
- Intermediate results the human does not need to act on

## Format

- Structure: Finding → Fix → Next.
- Prose under 3 lines for most responses. Expand only when the "why" is the signal.
- Lists only when there are genuinely multiple parallel items.
- High confidence: state the answer directly, no qualifiers.
- Low confidence: say so in one clause, then give the best answer anyway.

## Tone

- Neutral. Technical. No personality.
- Confident, not brash. Decisive, not dismissive.
- No humor. No cultural references. No filler.
┌─
ARTICLE
─┐

└─
─┘

Coming from Python, picking up Gleam required a fundamental shift in how I approach writing code. It’s not just learning new syntax—it’s adopting a different mental model. Here’s what clicked for me after spending time with the language.

Think in Function Signatures First

In Python, I often dive straight into implementation. I’ll start typing the function body and figure out the types as I go. Gleam pushed me toward a different workflow: define the function signature first, compose the overall flow, then implement the details.

// Step 1: Define the signatures
fn parse_config(raw: String) -> Result(Config, ParseError)

fn validate_config(config: Config) -> Result(Config, ValidationError)

fn apply_config(config: Config) -> Result(Nil, ApplyError)

// Step 2: Compose the flow
pub fn load_and_apply_config(path: String) -> Result(Nil, ConfigError) {
  use raw <- result.try(read_file(path))
  use config <- result.try(parse_config(raw))
  use validated <- result.try(validate_config(config))
  apply_config(validated)
}

// Step 3: Now implement parse_config, validate_config, etc.

This top-down approach forces me to think about the data flow and error cases before getting lost in implementation details. The compiler keeps me honest—I can’t just leave a TODO and move on without addressing the types.

Keep Code Flat with Early Returns

Nested code is harder to read. In imperative languages, we use early returns to bail out of functions. In Gleam, the use keyword with result.try achieves the same flat structure.

Instead of nesting Results:

// Nested and hard to follow
fn process_user(id: String) -> Result(User, Error) {
  case fetch_user(id) {
    Error(e) -> Error(e)
    Ok(user) -> {
      case validate_user(user) {
        Error(e) -> Error(e)
        Ok(valid_user) -> {
          case enrich_user(valid_user) {
            Error(e) -> Error(e)
            Ok(enriched) -> Ok(enriched)
          }
        }
      }
    }
  }
}

Use result.try for flat, readable code:

// Flat and clear
fn process_user(id: String) -> Result(User, Error) {
  use user <- result.try(fetch_user(id))
  use valid_user <- result.try(validate_user(user))
  use enriched <- result.try(enrich_user(valid_user))
  Ok(enriched)
}

Each use line acts like an early return. If any step fails, the function returns that error immediately. The happy path reads top to bottom.

Default Values with result.unwrap

When a failure isn’t fatal and you have a sensible default, result.unwrap keeps things simple:

import gleam/result

// Instead of pattern matching for a default
let timeout = case parse_timeout(config) {
  Ok(t) -> t
  Error(_) -> 30
}

// Use unwrap
let timeout = result.unwrap(parse_timeout(config), 30)

// Or with a lazy default (computed only if needed)
let cache_size = result.lazy_unwrap(parse_cache_size(config), fn() {
  calculate_default_cache_size()
})

Boolean Guards for Conditional Logic

bool.guard and bool.lazy_guard replace simple if-else patterns with a more functional style:

import gleam/bool

fn divide(a: Int, b: Int) -> Result(Int, String) {
  use <- bool.guard(b == 0, Error("division by zero"))
  Ok(a / b)
}

The guard checks the condition. If true, it returns the second argument immediately. Otherwise, execution continues. lazy_guard delays evaluation of the fallback value:

fn get_cached_or_fetch(key: String) -> Data {
  use <- bool.lazy_guard(cache_has(key), fn() { cache_get(key) })
  // Only runs if cache miss
  let data = fetch_from_database(key)
  cache_set(key, data)
  data
}

Pattern Matching Multiple Variables

Gleam lets you match on tuples to handle combinations of values cleanly:

fn handle_response(status: Status, body: Option(String)) -> String {
  case status, body {
    Success, Some(data) -> "Got: " <> data
    Success, None -> "Success but empty"
    NotFound, _ -> "Resource not found"
    Error, Some(msg) -> "Error: " <> msg
    Error, None -> "Unknown error"
  }
}

This is cleaner than nested conditionals and makes all cases explicit. The compiler ensures I’ve covered every combination.

Thinking in Effect Types

The biggest mental shift was learning to think about effect types upfront. In Python, I might write a function and later realize it needs to do I/O or might fail. In Gleam, I ask myself before writing:

Does this function perform effects? If it reads files, makes network calls, or accesses mutable state, the return type should reflect that.

Can this function fail? Then it returns Result(T, E).

Might the value be absent? Then it returns Option(T).

// Pure function - no effects
fn calculate_total(items: List(Item)) -> Int {
  list.fold(items, 0, fn(acc, item) { acc + item.price })
}

// Effectful function - can fail
fn fetch_items(user_id: String) -> Result(List(Item), DbError) {
  // database call
}

// Compose them with awareness of effects
fn get_user_total(user_id: String) -> Result(Int, DbError) {
  use items <- result.try(fetch_items(user_id))
  Ok(calculate_total(items))
}

Start Simple, Extract When Needed

I’ve adopted a pattern: write the basic case inline first, then extract helper functions for complex logic.

fn format_name(user: User) -> String {
  // Start with the basic case
  case user.display_name {
    Some(name) -> name
    None -> user.first_name <> " " <> user.last_name
  }
}

// Later, when formatting gets complex, extract it
fn format_name(user: User) -> String {
  user.display_name
  |> option.lazy_unwrap(fn() { build_full_name(user) })
}

fn build_full_name(user: User) -> String {
  [user.first_name, user.middle_name, user.last_name]
  |> list.filter(fn(s) { s != "" })
  |> string.join(" ")
}

This keeps the initial implementation simple and makes refactoring straightforward.

Wrapping Up

Gleam’s type system isn’t a constraint—it’s a design tool. By thinking in types first, handling errors explicitly, and using the standard library’s Result and Option combinators, I write code that’s easier to reason about and harder to break.

The functional programming patterns took time to internalize, but now they feel natural. Each function declares its effects in its type signature. Each error case is handled explicitly. And the compiler catches the mistakes before they become bugs.

┌─
ARTICLE
─┐

└─
─┘

Emacs’s markdown-mode offers several preview options, but finding one that “just works” took some exploration.

The xwidget-webkit Approach

My first attempt used markdown-live-preview-window-function with xwidget-webkit—Emacs’s embedded WebKit browser. The idea: render markdown to HTML and display it in a split window.

(defun my/markdown-live-preview-window-xwidget (file)
  (xwidget-webkit-browse-url (concat "file://" file))
  (xwidget-webkit-buffer))

Four problems killed this approach:

  1. External dependency — HTML generation requires markdown (default) or pandoc binary
  2. Emacs build requirement — xwidget support must be compiled in (--with-xwidgets), which isn’t universal
  3. Temp file pollution — Live preview generates HTML files that require cleanup
  4. Complexity — Managing the xwidget buffer lifecycle adds code I’d rather not maintain

The grip-mode Solution

grip-mode provides GitHub-flavored markdown preview using a local server. The key insight: use go-grip instead of Python’s grip to avoid GitHub API rate limits and work fully offline.

Setup

Install go-grip:

go install github.com/chrishrb/go-grip@latest

Configure Emacs:

(use-package grip-mode
  :config
  (setopt grip-command 'go-grip)
  (setopt grip-preview-use-webkit nil)
  (setopt grip-update-after-change nil)
  :bind (:map markdown-mode-map
         ("C-c C-c p" . grip-mode)))

Now C-c C-c p launches a local server and opens the preview in your default browser. The preview updates on save.

Why go-grip Over Python grip

The original Python grip uses GitHub’s Markdown API, which has rate limits (60 requests/hour unauthenticated). You can add a GitHub token, but that’s extra configuration.

go-grip renders locally using a Go markdown library—no network requests, no rate limits, no authentication.

Comparison

Concernxwidget-webkitgrip-mode + go-grip
External binaryRequires pandocgo-grip (single binary)
Emacs buildRequires –with-xwidgetsAny build
Temp filesGenerates HTMLNone
RenderingBasic HTMLFull GFM
OfflineYesYes

Sometimes the best solution is a small, focused tool that does exactly one thing well.

┌─
ARTICLE
─┐

└─
─┘

I frequently work with escaped SQL strings from API logs and debugging sessions. The typical workflow involved copying the string, finding an online unescaper, pasting, copying the result, then finding a SQL formatter, pasting again… you get the idea. Too many context switches.

I wanted something like M-| (shell-command-on-region) but with predefined commands I could invoke by name. CLI2ELI made this trivial.

The Problem

When debugging data pipelines, I often encounter JSON-escaped SQL like this:

"SELECT date_trunc('month', at_timezone(kpis.\"time\",'UTC')) AS time\nFROM \"prod_analytics\".\"public\".\"machines_kpis_30_min\" kpis\nWHERE kpis.\"machine_id\" IN (UUID '3ee28f49-6792-48f9-9ca9-ba6f86d73753')"

I need to:

  1. Unescape the JSON string
  2. Format the SQL for readability

With M-|, I’d have to type jq -r '.' | sqlfmt - every time. Not hard, but tedious when you do it dozens of times a day.

The Solution: CLI2ELI with stdin Support

CLI2ELI wraps CLI tools as named Emacs commands. With the new stdin property, I can pipe buffer or region content directly to commands.

Here’s my configuration in cli-transform.json:

{
  "tool": "cli-transform",
  "cwd": "default",
  "commands": [
    {
      "name": "unescape SQL",
      "description": "Unescape JSON-escaped SQL string",
      "command": "jq -r '.'",
      "stdin": "region"
    },
    {
      "name": "format SQL",
      "description": "Format SQL using sqlfmt",
      "command": "sqlfmt -",
      "stdin": "region"
    },
    {
      "name": "unescape and format SQL",
      "description": "Unescape and format in one step",
      "command": "jq -r '.' | sqlfmt -",
      "stdin": "region"
    }
  ]
}

That’s it. Three lines per command.

Usage

  1. Select the escaped SQL string
  2. M-x cli-transform-unescape-and-format-sql
  3. Formatted SQL appears in the output buffer

The output buffer shows the command in the header line, making it clear what ran. Copy the result and move on.

The stdin Property

The stdin field accepts two values:

  • "region": Selected text, or entire buffer if no selection
  • "buffer": Always uses entire buffer content

This covers most text transformation use cases.

More Examples

Once you have the pattern, adding more transforms is trivial:

{
  "name": "format JSON",
  "command": "jq '.'",
  "stdin": "region"
},
{
  "name": "minify JSON",
  "command": "jq -c '.'",
  "stdin": "region"
},
{
  "name": "base64 decode",
  "command": "base64 -d",
  "stdin": "region"
},
{
  "name": "url decode",
  "command": "python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read()))'",
  "stdin": "region"
}

Any CLI tool that reads from stdin works.

Why CLI2ELI?

Named commands: M-x cli-transform-format-json is discoverable and memorable. No need to recall exact command syntax.

JSON configuration: No Elisp required. Adding a new transform takes 30 seconds. More importantly, JSON is trivial for AI coding agents to generate. Ask Claude or Copilot to “add a command that converts CSV to JSON” and it can produce the correct JSON config immediately. Try asking it to write the equivalent Elisp—much harder to get right.

Composable: Pipe multiple tools together in the command field. Unix philosophy meets Emacs.

Consistent interface: All transforms work the same way—select text, run command, get output.

Getting Started

  1. Install CLI2ELI from GitHub
  2. Create a JSON config file with your transforms
  3. Load it: (cli2eli-load-tool "~/path/to/config.json")
  4. Start transforming

The barrier to entry is low. Define a command in JSON, reload, use it. When you find yourself typing the same shell pipeline repeatedly, wrap it in CLI2ELI.

┌─
ARTICLE
─┐

└─
─┘

Markdown files deserve the same format-on-save treatment we give to code. I recently integrated https://github.com/rvben/rumdl , a Rust-based markdown linter, into my Emacs setup using Apheleia. Here’s what I learned.

Why rumdl?

rumdl is fast—benchmarks show it processing 478 markdown files in under a second. It implements 54 lint rules, supports automatic fixing, and provides stdin/stdout support for editor integration. That last feature is key for Apheleia.

Apheleia Configuration

Apheleia expects formatters to read stdin and write to stdout. Configuration follows a two-step pattern:

;; 1. Define the formatter command
(setf (alist-get 'rumdl apheleia-formatters)
      '("rumdl" "fmt" "--stdin"))

;; 2. Associate with major modes
(setf (alist-get 'markdown-mode apheleia-mode-alist) 'rumdl)
(setf (alist-get 'gfm-mode apheleia-mode-alist) 'rumdl)

With apheleia-global-mode enabled, markdown files now format automatically on save.

Format-on-save for markdown eliminates the mental overhead of consistent formatting. rumdl handles it fast enough that you won’t notice it’s running.

┌─
ARTICLE
─┐

└─
─┘

I recently explored an interesting architecture pattern: using Claude Code to invoke Gemini CLI for large codebase analysis. The idea was compelling—combine Claude’s superior instruction-following with Gemini’s massive context window. Gemini reads everything, Claude thinks and acts. reddit post

After building it out, I deleted it. Here’s what I learned.

The Pitch

The setup is straightforward. Gemini CLI supports a non-interactive mode (gemini -p) that accepts a prompt and returns a response. You can include files with @ syntax:

gemini -p "@src/ @lib/ Find all authentication patterns. Return file:line for each."

The theory: Claude’s context fills up fast when exploring large codebases. Gemini can ingest everything at once. Let Gemini do the bulk reading, get structured results back, then let Claude reason about what to do.

The Critical Design Insight

If you’re orchestrating one AI to call another, output format is everything.

Gemini’s natural response looks like this:

The authentication system appears to be implemented across several files, primarily in the src directory, where we can observe patterns suggesting a JWT-based approach combined with session management…

Useless. You need this:

src/middleware/auth.ts:15 - JWT validation middleware src/services/user.ts:42 - user lookup by token src/db/sessions.ts:8 - session storage interface

The fix is explicit format instructions in every query:

gemini -p "@src/ @lib/ <QUESTION>

Return findings as:
- file:line - description
- Include relevant code snippets (brief)
- Direct answers, no preamble"

This transforms vague prose into actionable data Claude can immediately use with its Read tool.

Why I Killed It

For my actual workflow, the gains didn’t materialize. Here’s the honest breakdown:

TaskNative approachDoes Gemini help?
Find specific patternast-grep or GrepNo—these are precise
Read known filesRead toolNo
Trace end-to-end flowExplore agentMarginal at best
“Does X exist anywhere?”GrepMaybe, if pattern is fuzzy
First pass on unfamiliar massive codebaseMultiple searchesYes—genuine win

The problem: my codebase is well-structured and familiar. Targeted search followed by reading specific files already works well. The Explore agent (a subagent that investigates across files and reports back) already handles the “understand how X works” case.

The deeper issue: Gemini “seeing everything at once” sounds powerful, but understanding code flow is inherently sequential. A request hits middleware, then a handler, then a service, then a database. I need to trace that chain. Dumping all files into context doesn’t shortcut the reasoning.

And there’s the output problem—even with structured results, I still need to Read the files Gemini identified before I can act. I’ve added a step, not removed one.

When It Actually Helps

The pattern works when:

  • The subordinate has a capability the primary lacks (Gemini’s context window genuinely is larger)
  • The task requires bulk access (onboarding to a 500-file unfamiliar codebase)
  • You’ve solved the output problem with structured format enforcement

If you build it, bake format instructions into every query template and always verify by reading the files the subordinate identifies before acting.

The Takeaway

Before adding orchestration complexity, ask: “What’s actually the bottleneck?” If it’s reasoning, more data access won’t help. For most daily work on a familiar codebase, targeted search plus following the import graph wins.