HomeAboutPostsTagsProjectsRSS
┌─
ARTICLE
─┐

└─
─┘

Agent 跑起来需要一个环境:一组工具、一份记忆、一套上下文装配规则、一个控制循环、一层权限边界。这套东西现在叫 harness 。第一代 harness 是死的——工具在启动时注册,记忆结构由框架规定,控制循环写在代码里。现在大家在探索 meta-harness :让 agent 在运行时修改自己的 harness ,自己写新工具、自己改 prompt 、自己调控制流。

看到这个的第一反应几乎是必然的:这不就是 Lisp 吗 。 homoiconicity 、 macro 、 first-class environment 、 CLOS MOP 、 image-based 热更新——“程序在运行时改自己” 这件事, Lisp 在几十年前就做进了语言核心。所以问题似乎变成了:怎么把这两条线接上?

我花了一整个晚上跟 Gemini 辩这个命题,中途撞进了一篇刚放出来的论文,结论被彻底翻了一遍。最后收敛到的判断是:

Lisp 解决的是"如何无门槛地破坏性修改一个运行中的系统"。而 meta-harness 卡住的地方是"改完之后能不能干净地撤销"。这两件事看起来是一件事,其实完全不是。

这篇文章讲这个区别,以及一个 TypeScript 框架是怎么把 Lisp 传统里的几样东西重新实现了一遍——有些实现得比 Lisp 更好,有些至今还是空白。

Emacs 是四十年的反面实证

先说为什么"Lisp 早就做到了"这句话不成立。

Emacs 是这颗星球上运行时间最长的自修改系统。你可以在任何时刻 eval 一段代码覆盖掉任何核心函数,不用重启,改完立刻生效。从"能不能改自己" 这个角度看, Emacs 是满分。

然后你装了一个第三方包,发现它有问题,想干净地卸载它。

Emacs 提供了 unload-feature。读一下它的官方文档,会发现这是一份非常诚实的失败清单:

This command unloads the library that provided feature feature. It undefines all functions, macros, and variables defined in that library with defun, defalias, defsubst, defmacro, defconst, defvar, and defcustom.

Before restoring the previous definitions, unload-feature runs remove-hook to remove functions defined by the library from certain hooks. These hooks include variables whose names end in ‘-hook’ (or the deprecated suffix ‘-hooks’), plus those listed in unload-feature-special-hooks, as well as auto-mode-alist. This is to prevent Emacs from ceasing to function because important hooks refer to functions that are no longer defined.

If these measures are not sufficient to prevent malfunction, a library can define an explicit unloader named feature-unload-function.

把这几句话拆开看,每一句都在承认同一件事:

  1. 卸载是靠猜的。它扫描 load-history,撤销那些通过标准 def* 形式定义的东西。但凡这个包用 setq 改了别人的全局变量、往某个 alist 里 push 了一项、advice-add 了一个函数——这些都不在 def* 的名单里。
  2. hook 清理是靠命名约定的。它移除的是"名字以 -hook 结尾"的变量里的函数,外加一张硬编码的特殊 hook 白名单 unload-feature-special-hooks。一个 hook 只要没按这个命名约定起名,也不在白名单里,里面的函数就留在那儿了。
  3. 文档明说这可能不够(“If these measures are not sufficient to prevent malfunction”),于是把兜底责任推回给包作者:你自己写一个 feature-unload-function 吧。

第三条是最关键的。撤销的正确性,在 Lisp 传统里从来是一种开发者纪律,不是系统性质。 作者忘了写、写漏了、写错了,系统不会知道,你也不会知道——直到几小时后某个行为莫名其妙地不对了。

而且注意 remove-hook 那句话的动机:它清理 hook 不是为了"恢复原状",而是为了防止 Emacs 直接不能用(因为重要的 hook 指向了已经不存在的函数)。这是在做损害控制,不是在做回滚。

人类遇到这种情况有个终极方案:重启 Emacs 。丢掉的无非是几个 buffer 和一点撤销历史,可以接受。

但自演化的 agent 没有这个方案。 论文里那句话说得比我狠:

even worse, a faulty self-modification can disable the very process needed to recover.

一次坏的自我修改,会搞死那个本来用来恢复它的进程。当 agent 改坏了自己的控制循环,那个负责"重启并恢复"的中枢,本身已经瘫了。

这就是"能改"和"能撤" 的区别所在。 Lisp 把前者做到了极致,后者一直是空的。

有人把这件事形式化了

那篇论文叫 《A Programming Paradigm for Spatiotemporal Composability》 ,作者是 Yifan Shi 、 Wei Zhang 、 Tianyi Cui ,北大 + DeepSeek-AI , 2026 年 8 月 13 日的 draft 。配套实现叫 Cordis , TypeScript 写的, MIT 协议。 DeepSeek Harness ( dsh )建在它上面 ——“everything is a plugin"那套说法就是从这儿来的。

它把动态组合拆成两个正交维度:

  • temporal composability(时间可组合性):一个组件被移除时,它装上去的所有副作用能被完整回滚。
  • spatial composability(空间可组合性):组件之间的依赖能被声明,并在依赖出现/消失/换身份时被响应式地重新解析。

对应的两个机制:

  • revertible effects:每一次对 context 的变换都携带一个 inverse , runtime 追踪它,卸载时按 LIFO 顺序应用。
  • reactive coeffects : context 每变一次,就按每个组件声明的 coeffect specification 通知它。

论文明说了自己的动机就是 self-evolving agent harness ( §1.2.2 ),也明说了 OS 和容器只是 coarse-grained workaround(§1.2.3 ) ——操作系统在进程粒度上给你 temporal ,容器编排在服务粒度上给你 spatial ,代价是每次重启丢掉所有进程内累积的状态:缓存、连接、在途计算。粒度对不上。

有意思的是 §6.4 :论文承认这套范式是 language-agnostic 的,并且列出了宿主语言需要满足的最小条件:

  • temporal 要求 :闭包( inverse 必须能作为一个值被捕获,连同它要恢复的状态一起),以及运行时能引入/ 撤回模块( Node 的 module registry 、 dlopen/dlclose 、 wasm instance )。
  • spatial 要求 :类型层能表达依赖( Haskell typeclass 、 Rust trait 、 TS module augmentation ),运行时能透明地中介访问( JS Proxy 或 Python 的 __get__),否则就退回 runtime reflection ,牺牲类型安全。

看这个清单会有一种熟悉感:一等公民的闭包、运行时重定义、透明拦截——这几样能力全都是 Lisp 传统的看家本领。但论文最后选了 TypeScript ,而且它需要的每一样, TS 都拿现成机制实现了。

下面逐条对照。

一、 revertible effect : Lisp 有配对语义,但绑错了东西

看到"每个 effect 配一个 inverse” , Lisp 程序员会立刻想到 unwind-protect ( Common Lisp )和 dynamic-wind ( Scheme)。这些不就是几十年前就有的 before/after thunk 配对吗?

有意思的是,论文 §7.3 的 Related Work 把这个领域切成了四类:

  • stateful forward migration : Erlang/OTP 的 code_change/3 、 webpack/Vite 的 HMR——带着状态往前迁移,不回滚 effect
  • developer-authored recovery : OSGi 、 VSCode 、 saga 补偿、 algebraic effect handlers 、 React useEffect——inverse 是一项 “unenforced duty”,忘了就静默泄漏
  • statically scoped reversal : STM 、可逆计算、 Janus 、 RCCS 、线性类型、 RAII 、 Rust ownership——作用域预先固定
  • interposed reclamation : Nooks 那种在内核接口上记录扩展获取了什么资源

这四类里一个 Lisp 机制都没有。 提了 Erlang ,提了 React ,提了 saga ,就是没提 unwind-protect

这不是疏漏,分类是准确的。关键差别在触发时机绑定在什么上

;; unwind-protect : cleanup 绑定在调用栈帧上
(unwind-protect
    (do-something)      ; 栈帧建立
  (cleanup))            ; 栈帧一退出,立即触发

unwind-protectdynamic-wind 的 cleanup 是由调用栈退出触发的。控制流一旦正常返回,或者通过 non-local jump 跳出这个块,清理代码立刻执行。

而 agent 需要的语义正好相反:它装上一个新工具之后,这个修改必须在未来无数个独立的 turn 、异步请求、控制循环里持续生效 ——绝不能在当前这个 turn 的栈退出时就自动撤回。 撤回的时机是"这个组件被卸载了",那是一个跟调用栈完全解耦的事件,可能发生在几千次调用之后。

所以 Cordis 的 inverse 不能挂在栈上,它必须是一个独立的、跨越时间的一等公民数据结构。看它的实现( Algorithm 1 ):

function effect(ctx, callback)
    armed ← true
    task ← execute(callback, () ↦ armed)
    async function dispose()
        if not armed then return
        armed ← false
        recover ← await task
        recover()
    ctx.dispose ← dispose ∘ ctx.dispose
    return dispose

ctx.dispose ← dispose ∘ ctx.dispose 这一行是整个设计的核心:每个新的 inverse 被前插到父 context 的累加器上,于是回滚天然是 LIFO 顺序。而且子 effect 的 inverse 本身也是父 context 上的一个 effect——这个递归结构让整棵组件树的卸载能级联下去。

还有一个细节值得注意,armed 标志同时干了两件事:作为 guard 让进行中的迭代能在步骤边界停下来(部分回滚,只撤销已经执行的那部分),以及保证 dispose 最多只触发一次。论文解释了为什么第二点是必须的:

Firing twice would apply an inverse at a state no application of the effect produced, where nothing holds it to reverting anything.

在一个不是由该 effect 产生的状态上应用它的 inverse ,没有任何东西能保证它撤销的是正确的东西。这是一个 Lisp 的 unwind-protect 从来不需要考虑的问题——因为栈帧只会退出一次。

** 判断: Lisp 有配对语义,但它把配对绑在了词法作用域上。在 Lisp 里实现组件级的回滚,你同样得手写一套外部的 inverse 追踪表,语言本身帮不上忙。**

二、 MOP 拦截: TS 的 Proxy 粒度更广

CLOS MOP 是 Lisp 世界里最接近"可编程运行时"的东西:compute-applicable-methods 可以改方法派发,slot-value-using-class 可以拦截槽位访问,:before/:around/:after 可以在方法调用前后织入逻辑。用来做 agent 的工具注册表拦截器(权限校验、沙盒包装、 token 审计、结果写回记忆)看起来非常合适。

Cordis 用 JS Proxy 实现了同一件事( Algorithm 6 ):

function resolve(ctx, key)
    fiber ← ctx.fiber
    repeat
        if key ∈ fiber.committed then return fiber.committed[key]
        if key ∈ fiber.inject then throw INACTIVE_ACCESS
        if fiber = root then throw UNDECLARED_ACCESS
        fiber ← fiber.parent.fiber

组件写 ctx.someService 就像访问一个普通属性, Proxy 的 get trap 拦下来,沿着 fiber 链向上走,在第一个 committed view 里绑定了这个 key 的 fiber 处返回。

这里有个设计比裸的 ctx.get(key) 讲究得多。论文自己点出了区别:

ctx.get(key) is a lookup against the store that returns the bound value or nothing and never fails, whereas the proxy resolves against the accessing fiber’s own view and enforces the coeffect specification 𝑑 at the point of use.

Proxy 解析的是访问者自己的 view,不是全局 store 。这带来两个后果:

  • 没声明的依赖直接抛错UNDECLARED_ACCESS)。论文 §6.3 说这在结构上等价于 capability-based security : inject 声明是能力请求, context proxy 是能力中介,而且因为声明是静态的, orchestrator 可以在加载时审查一个组件要什么权限,而不是等它运行时才发现。
  • 组件在自己被拆卸的过程中仍然读得到那个触发拆卸的依赖(因为读的是已提交的 view 而非 store )。这是一个很微妙的性质 ——依赖消失导致你被卸载,但你在跑清理逻辑时还需要用那个依赖。

跟 CLOS MOP 比,两处差别:

CLOS MOPJS Proxy
拦截锚点class 层次与 generic function 派发引用边界,任意属性读写 / 函数调用 / construct
前提假设系统由 class/method 元对象协议构成无,任何对象都能包
撤销需自己拆掉 methodrevocable proxy ,撤销后访问直接抛错
类型契约动态类型,无静态依赖拓扑TS module augmentation ,编译期可查

revocable proxy 这一点在 agent 场景里价值不小:撤销之后所有残留引用的访问立刻抛错,而不是继续指向一个僵尸对象。这正好对应了 Lisp 那边最难受的地方——fmakunbound 只能解绑符号,那些已经被闭包捕获的旧函数指针、存在某个 hook 列表里的旧值,一个都够不着。它们会继续正常工作,指向一份本该消失的实现。

判断:这一格 TS 不只是"够用",是确实做得更完整。

三、 continuation : generator 就够了, call/cc 是过度武装

这条是我在讨论里最坚持的一点,最后被论文正面驳回了。

我的论点是: agent harness 最痛的是上下文分叉与回滚——试一条路径失败了,正确做法是回到分叉点,而不是把失败堆进 context 继续污染后续推理。这在 Scheme 里就是 call/cc,是 Lisp 家族真正独有、其他语言学不来的东西。

论文里有一句话直接处理了这个:

The 𝖬𝖺𝗒𝖻𝖾(𝔈iter) continuation makes a boundary available between any two consecutive iterations… In this sense the effect iterator is a reified delimited continuation, the structure that mainstream languages expose through the yield operator, so the model maps directly onto the generators they already provide.

它把 delimited continuation 落到了 generator/yield 上。组件的加载过程是一个 effect iterator ,每次 yield 出一个 inverse ,两次迭代之间就是一个天然的边界 ——在这个边界上 context 是"到目前为止的迭代所造成的样子",累加器恰好能回滚这些、且只回滚这些。

关键在于组件生命周期是 single-shot 的 :进入( yield effects ) → 逆向离开( run inverses )。它不需要 multi-shot——不需要从同一个点重新进入两次。而 generator 提供的正是 single-shot delimited continuation , call/cc 那种 multi-shot 的完全通用能力在这里是过度武装,代价是破坏整个调用栈模型。

那推理路径的分叉呢?那是另一个问题,不该混为一谈:

组件装卸的回滚推理路径的分叉
处理对象harness 自身结构(工具、依赖、权限)message 列表与推理路径
机制revertible effect + inverse状态快照 / 树搜索
需要的语义single-shot ( yield 够用)multi-shot
典型系统CordisTree of Thoughts 、 LATS

而推理分叉在 LLM 体系里的实质是一个数组的浅拷贝——message list 是纯数据,没有副作用,克隆一份就完成分叉了。真正需要 continuation 的从来不是这一层。至于跨进程崩溃的持久化恢复,工业界已经有 Temporal 、 Restate 、 DBOS 那套 durable execution : event sourcing + 确定性重放,同样不需要语言暴露 call/cc

判断:这一格 Lisp 输得比较彻底。它的优势是"完全通用的 multi-shot continuation",而这个通用性在 agent 场景里没有对应的需求,代价却实打实。

四、 condition/restart : Cordis 主动放弃了这一层

前三条 Lisp 都没占到便宜。第四条反过来了。

Cordis 的失败语义在 §4.3.4 ,规则叫 L-Raise :

                  𝜃𝑛 = 𝖱𝖾𝗅𝗈𝖺𝖽𝗂𝗇𝗀(𝑖, 𝑔, 𝜔)   𝑖(𝛾) = 𝖫𝖾𝖿𝗍(𝜉)
                 ─────────────────────────────────────────── L-Raise
                     𝛾 ⟶ 𝛾[𝜃𝑛 ↦ 𝖴𝗇𝗅𝗈𝖺𝖽𝗂𝗇𝗀(𝑔, 𝜔, 𝜉)]

翻译成人话:组件激活过程中某一步抛错了, fiber 直接路由进 Unloading,把已经累积的 inverse 全部应用掉,最后停在 Inactive(ξ) 携带那个错误。而 L-Begin 的前提是 Inactive(⊥)——所以一个失败的 fiber 不能从错误状态重新进入生命周期。论文的措辞是:

this is the substance of the outcome, which withholds a fiber whose effect function has shown itself to be unsound in the state it ran against rather than retrying it against an unchanged environment.

一个 effect function 已经在当前状态下证明了自己是 unsound 的,那就不要在环境没变的情况下重试它。

这个设计很干净,但它意味着 Cordis 的失败语义是全量回滚 + 拒绝重入,没有任何中间状态。

对比 Common Lisp 的 condition system 。那套东西的核心不是 “错误处理”,而是把"报告错误"和"决定怎么办"这两件事分开,并且在决定之前不退栈。底层函数 signal 一个 condition ,同时用 restart-case 声明几个可能的恢复路径;栈上层的 handler 看到这个 condition ,选一个 restart ;然后 从出错的那个点原地继续,栈从来没有被销毁过。

为什么这对 agent 特别重要,举个具体的例子:

agent 挂载一个新技能,这个技能的初始化过程有 8 步。跑到第 6 步 check_api_key 时网络超时了。

Cordis 的行为:前 5 步做的所有事全部回滚, fiber 停在 FAILED ,前面那些可能很昂贵的初始化工作(下载模型、建立连接、预热缓存)全部作废。

agent 真正想要的行为:挂起在第 6 步,把"API key 校验超时"这个 condition 连同几个 restart 选项(use-backup-keyretry-onceskip-and-degrade)一起交给上层的 meta-agent ,让它决定,然后 从第 6 步继续往下走

这个差别在 agent 场景里被放大了,因为回滚的代价不只是重算,还有上下文 。 agent 每次重来一遍,失败的轨迹会堆进 context , token 烧掉了,而且下一轮推理还会被那些失败轨迹污染。

Cordis 为什么不做?我认为这是为形式化定理付的代价,不是疏忽。它的 metatheory 要证 confluence 和 progress ,而这两个定理都依赖于一个事实:所有的 outcome 只能经由 L-Unload 到达(论文原话:“Routing a failure like every other deactivation is what makes every outcome reachable only through L-Unload, which is the single fact Theorem 59 turns on”)。如果允许在 L-Raise 时不退栈、向外层暴露任意的 restart 闭包,状态机的变迁就变成非确定的了,所有关于 effect 生命周期成对映射的证明会一起崩掉。

** 判断:这是真空白。 condition/restart 那套"不退栈的错误协商"语义,在 Cordis 里明确缺席,而 agent 确实需要它。谁想补,得在 Cordis 的状态机之外单独建一张挂起-恢复网。**

五、 CLOS 的实例迁移协议:至今没有对手

第二个空白,也是我觉得最被低估的一个。

设想这个场景:agent 决定改自己长程记忆的数据结构。比如原来记忆条目是 {content, timestamp},现在它要加一个 embedding 字段,同时把 timestamp 从字符串改成结构化的时间对象。

代码好改。问题是:内存里已经存在的那几万条旧结构怎么办?

TypeScript/Cordis 的答案是丢弃重建——组件卸载时 inverse 跑一遍,新组件从干净状态重新装载。论文自己承认了这点:

Cordis reverts the old component’s tracked effects and reapplies the new component’s from a clean slate, so a component’s own in-memory state does not survive a reload unless placed in a longer-lived dependency, and layering DSU-style forward migration atop revertible effects is future work.

组件自己的内存状态不会挺过一次重载,除非你把它放进一个生命周期更长的依赖里。而 DSU 式的向前迁移,论文明说是 future work 。

Common Lisp 在 1988 年就把这个问题解决了。当一个类被重新定义时, CLOS 会自动对内存中所有现存实例调用 update-instance-for-redefined-class。看它的签名:

update-instance-for-redefined-class
    instance added-slots discarded-slots property-list &rest initargs

关键是 property-list 这个参数。 CLHS 的原文:

When make-instances-obsolete is invoked or when a class has been redefined and an instance is being updated, a property-list is created that captures the slot names and values of all the discarded-slots with values in the original instance. The structure of the instance is transformed so that it conforms to the current class definition.

被删掉的槽位的被抢救出来,装在 property-list 里交给你。于是你可以写一个方法,把旧数据转换成新表示:

(defmethod update-instance-for-redefined-class :before
    ((pos x-y-position) added deleted plist &key)
  ;; Transform the x-y coordinates to polar coordinates
  ;; and store into the new slots.
  (let ((x (getf plist 'x))
        (y (getf plist 'y)))
    (setf (position-rho pos) (sqrt (+ (* x x) (* y y)))
          (position-theta pos) (atan y x))))

写完这个方法,然后重新 defclassx/y 槽换成 rho/theta——内存中所有旧实例会自动迁移,笛卡尔坐标被算成极坐标存进新槽位。规范里那句注释说得很清楚:“All instances of the old x-y-position class will be updated automatically.”

这套机制有三个性质在今天看依然罕见:

  1. 惰性且自动。你不需要遍历所有实例,也不需要知道它们在哪儿。运行时在实例被访问时拦截并迁移。
  2. 旧值被保留而非丢弃property-list 让迁移逻辑能读到被删槽位的原始值——这是"迁移"和"重建"的分水岭。
  3. 迁移逻辑是一个普通方法,可以用 :before/:after/:around 组合,可以按类分派。

回到 agent 场景:一个能改自己记忆 schema 的 agent ,恰恰最需要这个。因为 记忆是那个绝对不能丢的东西——你可以重建工具注册表、重建连接池,但你不能把 agent 积累的记忆倒掉重来。

**判断:这一格 Lisp 至今没有对手。 TS/Cordis 完全没涉及,论文自己标为 future work 。 **

汇总

状态
revertible effect + reactive coeffectCordis 已实现( TS )。 Lisp 的 unwind-protect 绑在栈帧上,不构成先例
MOP 拦截契约TS Proxy 覆盖,且粒度更广(任意属性 + revocable + 静态类型契约)
continuation 分叉generator/yield 的 single-shot 已足够;推理分叉只是数组浅拷贝
condition/restart 的原地挂起协商真空白——Cordis 为 metatheory 主动放弃
CLOS update-instance-for-redefined-class真空白——agent 改自己记忆 schema 时,旧实例只能丢弃重建

所以最后的结论是借其神,弃其形:不要用 Lisp 写 agent ,但要把它沉淀的语义搬到现代运行时上。而这张表更有意思的地方在于, ** 前三行已经被工业界兑现了, Lisp 剩下的全部价值集中在后两行**——都是"出事的时候和改结构的时候,怎么不丢现场地过渡"。

但还有两件事没人解决

写到这里必须补充:上面这张表全部是关于 harness 内部的。而 agent 最可怕的错误全都在 harness 外面。

** 第一, Cordis 保护的是脚手架,不是世界。**

论文 §6.1 用 system boundary 划了条线: boundary 内的位置能被独占修改和恢复,操作被追踪; boundary 外的操作直接是 idΓ——既不追踪也不恢复。而 agent 干的那些真正危险的事—— 发出去的邮件、 merge 掉的 PR 、花掉的钱、污染的生产数据库、发给用户的消息 ——一件不落全在 boundary 外面

论文对此给了两条路:withhold(把输出压住,等状态确定持久化了再发,即 output commit problem )或者 compensate(补偿,删掉已创建的文件、退掉已收的款)。但它也诚实地指出,补偿只能恢复到"应用自己定义的某种等价",比形式化的 粗得多,而且整套 metatheory 的交换性证明是对 做的,换成粗粒度的等价关系后需要重新证明

所以媒体上那句"DeepSeek Harness 杀死了 agent 黑箱"是过度包装了。它让 agent 改坏自己时宿主进程不至于崩,这很有价值,但它挡不住 agent 把生产库删了。

** 第二, inverse 的正确性没有人保证——这才是真正的坑。**

论文的核心卖点是"完整恢复是系统的不变量,而不是开发者的纪律"。但这个承诺有个前提,论文自己在 §5.1.1 交代得非常清楚:

What the operation does not check is the witness that 𝔈Γ∗ carries: the callback supplies an inverse, and that the inverse recovers the effect it accompanies is an obligation on the component author rather than a property the runtime verifies.

inverse 真的撤销了对应的 effect——这是组件作者的义务,不是 runtime 验证的性质

Theorem 61 ( Recovery exactness )证明的是:应用累加器得到的状态,等于那些步骤从起点出发本来会产生的状态。这是 结构层的保证——“执行了 A ,卸载时一定会执行 Inverse(A),顺序正确,只执行一次”。它不是语义层的保证——“Inverse(A) 真的抹掉了 A 的影响”。

人类写组件时,这个义务是合理的。但 meta-harness 的前提是 agent 自己生成组件。于是:

{
  do:   () => exec("rm -rf /data"),
  undo: () => console.log("restored")
}

在 Cordis 的状态机看来,这个组件的生命周期完美闭环。 装载时执行了 effect ,卸载时执行了 inverse , LIFO 顺序正确,没有泄漏,所有定理都成立。数据没了。

这是让囚犯自己写赦免状。

而且它比"没有回滚机制"更危险,因为你以为已经回滚干净了。没有回滚机制时你至少知道自己在裸奔,会去做快照、会小心;有一个假的回滚保证时,你会基于它去做更激进的自我修改。

我们在讨论里推出过一个结论:任何能自改的系统都必须有一个不可被 agent 修改的固定点。 Schmidhuber 的 Gödel machine 里,那个固定点是效用函数和证明检查器——如果证明检查器能改自己,系统第一步就会证明"把效用设成无穷大是最优策略"。我们原本以为在 agent harness 里,这个固定点是评价器和沙盒那一层。

现在看它其实还要更低一层:基础 atomic effect 的 inverse 必须由人类预定义并冻结。 agent 只能组合这些安全原语来构造复合组件(论文说了,复合的 inverse 由组合自动导出,只有 atomic 的需要手写)——但它不能自己发明一个带副作用的新原语,再自己给它配一个 undo 。

Gödel machine 的 proof checker 问题,换了个位置又出现了一次。

所以这个直觉应该怎么修正

回到最开始那句"Lisp 早就做到了"。

它不对,但它错的方式很有价值。准确的表述是:

Lisp 几十年前解决的是"如何无门槛地破坏性修改一个运行中的系统" ( unconstrained runtime mutation )。而 meta-harness 真正需要的是"如何让每一次自我修改都携带确定性的逆操作,并在撤销时不破坏依赖拓扑" ( governed composability )。 Lisp 从未在系统层面内建后者,它反而为状态污染大开了方便之门。

Lisp 给的是无限的可写。它没给可撤,也没给依赖治理 。 Emacs 用四十年证明了:光有前者,你会得到一个谁也不敢干净卸载任何东西的系统。

而在 meta-harness 这条路上, Lisp 剩下的价值不在语言,在它留下的两个至今没被工业界兑现的语义——condition/restart 的原地挂起协商,和 CLOS 的实例重定义迁移协议

它们恰好都在同一个位置上:出事的时候,和改结构的时候,怎么不丢现场地过渡。

这也正是一个会自我修改的系统最脆弱的两个时刻。


论文: A Programming Paradigm for Spatiotemporal Composability ( Shi, Zhang, Cui ;北大 + DeepSeek-AI , 2026-08-13 draft )。实现: cordiverse/cordis 。 Emacs 卸载语义引自 GNU Emacs Lisp Reference Manual §16.9 , CLOS 协议引自 CLHS: UPDATE-INSTANCE-FOR-REDEFINED-CLASS

┌─
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.