Jev 上下文压缩解剖:把 LLM 摘要换成不说话的判断模型

一句话总结:这个插件把「上下文快满了怎么办」从「让模型把整段对话重写一遍」改成了「逐条问一个不说话的模型——这条工具调用该不该留」。前者会丢掉用户的原话、会编、还要再跑一次大模型;后者只做删除和截断,一个字都不改

00 先看结论:五句话

  1. 它的压缩是「减法」不是「改写」。 用户和助手的文本永远逐字不变,它只对工具调用(tool_use)和工具结果(tool_result)动手。
  2. 删什么由一个概率决定。 对每个调用问两个「是非题」,Jev 返回 0~1 的概率,跟阈值 keepThreshold(默认 0.5)比大小,得到三种动作之一。
  3. 三种动作是三级瀑布: 整体保留 → 保留调用但把结果截成前 300 字加一行说明 → 调用和结果一起删。中间那一档是这套设计最值钱的地方。
  4. Jev 每次都看到整段对话。 状态里工具输出被换成一行 ok, 4213 chars (omitted),但要塞进 25k token 预算,于是有八级渐进的「缩身阶梯」。
  5. 失败就回退,永不变差。 缺 key、请求失败、格式不对、压缩率不到 25%,统统退回 Claude Code 官方的摘要式压缩。

一句话把整套设计讲透: 「可重放的东西可以删,不可重放的东西一个字都不能动。」工具结果是可重放的——重跑一次工具、重读一次文件就回来了,所以它敢删;人说的话和助手说的话不可重放,所以它永不碰。整篇源码都在这条边界上做文章。

01 问题:上下文里的「垃圾」在哪

一个跑久了的编码 Agent 会话,token 花在哪里:

  • 真正的人类部分(需求、约束、偏好)——占比很小,通常几百到几千 token。
  • 助手的推理文本(「我先看测试再改实现」)——中等,密度高,是决策链的痕迹。
  • 工具调用与工具结果——压倒性的大头。一次 Read 一个 800 行文件可能是 8000 token;一次 npm test 的失败日志可能 5000 token;一次 Glob 的文件树可能 3000 token。50 轮的会话里,这类内容轻松吃掉 70%~90% 的窗口。

而这些大块内容里,真正「当时有用、后来永远有用」的比例极低:

已经过期的大块必须逐字留住的小块
读了一个最后证明无关的模块(legacy 实现、另一个包的同名文件)你定的约束:「不要改 src/generated」
同一段测试的中间态输出——修完 bug 后,那次 FAIL 的完整日志不再需要,只要知道「曾失败过、原因是什么」准确的报错文本与堆栈位置(差一个字符就查错方向)
一次 ls / Glob 的完整文件清单最终通过的那次测试输出(证明「改对了」的证据)
探索期的试错调用(先试 A 方案再试 B),决策已经做出关键文件的真实内容;助手的结论性推理

传统做法是把旧的几轮喂给一个 LLM:「总结成一段」。三个问题:

  1. 不可验证。 摘要写完你没法检查它漏了什么——漏掉的恰恰是「当时不知道将来有用」的。
  2. 不可逆。 原文被替换掉了,想找回来只能重新读文件、重新跑命令。
  3. 粒度错。 摘要是「整段重写」,而真正的需求是「这块删、那块留」。
① 改写式压缩(主流)
     [msg][tool_result 8k][msg][tool_result 5k]
       --LLM 总结-->   一段新写的摘要
                       原文永久消失 · 无法核对漏了什么

② 删除式压缩(本项目)
     [原文][保留并截断][原文][删除]
       ---------->     留下的部分一模一样,只是变少了
                       每个被删项都有理由 · 可逐条审阅 · 助手可重跑工具

赌注就在这:工具内容是可重放的,所以「删对了」的收益远大于「删错了」的代价;人类的文本不可重放,所以永不触碰。

① 改写式压缩(主流) 把 N 条旧消息交给 LLM,产出一条新消息 msg tool_result 8k msg tool_result 5k LLM 总结 一段新写的摘要 原文永久消失 · 无法核对漏了什么 ② 删除式压缩(本项目) 不改一个字,只把"确认不再需要"的调用与结果摘掉 原文 保留并截断 原文 删除 留下的部分一模一样,只是变少了 每个被删项都有理由 · 可逐条审阅 · 助手可以重跑工具 赌注:工具内容是可重放的,所以"删对了"的收益远大于"删错了"的代价;而人类的文本不可重放,所以永不触碰。
图 1 · 两种压缩哲学的分岔点:改写会损失不可恢复的信息,删除只损失可重新获得的信息

02 底座:Jev 是什么,为什么它够用

TypeSafe 在 2026 年 9 月放出 Jev,自称 System One Model(取自卡尼曼的「系统一 / 系统二」)。它不生成文本:你发一个 state 和一串有类型的 questions,它回一串有类型的 answers,每个答案带概率和置信度。这个仓库的提交历史比 Jev 公开还早两天——它是最早一批吃这只螃蟹的用法之一。

问题有三种类型,这个项目全程只用其中一种:

类型语义返回
choice从最多 255 个选项里选一个choice + probabilities + confidence
score按有序等级打分score + 概率分布
noul判断一条陈述是否为真一个 0~1 的 noul 概率

为什么是 noul?因为这个插件的每个问题都是一个是非判断题,而不是分类题。与本项目相关的还有三个特性:

  • 问题并行且互不影响。 多个问题对同一份 state 并行推理,官方说「加问题几乎不增加响应时间」——这直接决定了后面用批量提问而不是逐个问。
  • 概率是校准过的。 所以可以当阈值用:≥0.5 就留 这种逻辑才成立。
  • 不自由生成字符串。 所谓「零幻觉」指的是它写不出你没定义的答案,程序不用写解析兜底。

HTTP 契约长这样,一个请求里塞两样东西——state(状态)和 questions(问题):

POST https://api.typesafe.ai/v1/systemone
{
  "model": "jev-latest",
  "state":  { "context": "...", "goal": "...", "history": [ ... ] },
  "questions": {
    "call_t3": {
      "type": "noul",
      "instructions": "Tool call t3 (Bash) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next"
    },
    "result_t3": {
      "type": "noul",
      "instructions": "The full output of tool call t3 (Bash, 4213 chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do"
    }
  }
}

响应侧只有两个数字:

{
  "model": "jev-latest",
  "answers": {
    "call_t3":   { "type": "noul", "noul": 0.86 },
    "result_t3": { "type": "noul", "noul": 0.93 }
  },
  "usage": { "input_tokens": 24118, "output_tokens": 0 }
}

洞察: Jev 把「压缩策略」这件模糊的工程决策,变成了一个可调的超参数。传统摘要方案的「压多少」是黑盒(你只能给一句「尽量详细」);这里压多少由 keepThreshold 一个数字控制,而且每个决定都附带当时的概率,出问题时你可以逐条复盘:「这条被删是因为 result=0.12,是不是我把阈值调太低了?」

03 架构:三层与七个文件

LAYER 1 · 宿主  Claude Code 引擎(2.1.274+,需开 function hooks)
    · 派发 session.compact / turn.complete
    · 提供 $.http.fetch、$.ui.log/toast、$.settings.read
    · 内置摘要式压缩作为回退
  ↓ session.compact(messages …)
  ↑ 返回 { messages } 顶替摘要,或 next(event) 回退

LAYER 2 · 适配器  hooks/fast-jev.ts(约 310 行)
    · 读 userConfig → 找 key(option → env → settings.json)
    · 换算 token → 调库 → 映射回 SessionMessage
  ↓ compact(messages, asker, options)

LAYER 3 · 库  types.ts · state.ts(状态拟合)· compact.ts(主流程)
              request.ts · client.ts · index.ts
    · 对外只暴露 compactMessages();也导出全部构件供他人组装
路径作用备注
src/核心库,7 个文件约 700 行零运行时依赖,只用原生 fetch
hooks/Claude Code 适配器 + hooks.json唯一依赖宿主类型的地方
.claude-plugin/plugin.json(8 个 userConfig)+ marketplace 清单仓库根即插件根,无需发布步骤
types/claude-code.d.tsClaude Code 2.1.274 生成的类型快照升级宿主后需重新生成并复核
tests/vitest 单测,含一个 fakeJev单测完全不联网;只有 npm run demo 打真实 API
demo/JevDemomacOS SwiftUI 小应用,把压缩流程演成动画纯演示,不调 API,供录屏用
LAYER 1 · 宿主 Claude Code 引擎(2.1.274+,需开 function hooks) 派发 session.compact / turn.complete · 提供 $.http.fetch、$.ui.log/toast、$.settings.read · 内置摘要式压缩作为回退 session.compact(messages …) 返回 { messages } 顶替摘要,或 next(event) 回退 LAYER 2 · 适配器 hooks/fast-jev.ts(约 310 行) 读 userConfig → 找 key(option → env → settings.json)→ 换算 token → 调库 → 把结果映射回 SessionMessage resolveHookConfig() jevAsker(fetchFn) compactSession() toSessionMessages() summarize() compact(messages, asker, options) LAYER 3 · 库(可独立 npm 使用) types.ts state.ts · 状态拟合 compact.ts · 主流程 request.ts client.ts index.ts 对外只暴露一个函数:compactMessages();也导出全部构件(collectToolCalls / fitState / batchCalls / decideCall / applyDecisions)供他人组装
图 2 · 三层架构:引擎(事件源)→ 适配器(协议翻译)→ 库(纯逻辑,可替换传输层)

设计要点:库与宿主之间只靠一个接口解耦——JevAsker compact() 只要求传入的对象有一个 ask(state, questions) 方法。于是同一个算法可以跑在原生 fetchJevClient)、Claude Code 的 $.http.fetchjevAsker)、或者测试里的一个纯函数上。这也是它敢把「库」和「插件」放在一个仓库里的原因——适配器的替换成本只是一次对象构造。

04 七步流水线,逐步拆源码

整个压缩的主函数不到 60 行,就是这七步的编排:

1 collectToolCalls  →  2 fitState  →  3 questionsFor  →  4 batchCalls
                                                            ↓
8 CompactResult  ←  7 applyDecisions  ←  6 decideCall  ←  5 ask(并发)

贯穿全程的两条不变量:

  • pinned 的消息(第一条 + 最新 N 条)永不进入候选集,因此第一条里的用户约束永远安全。
  • 结果永不先于调用消失:drop_call 时调用与结果一起走,drop_result 时两者同步截断,绝不产生「孤儿结果」。
① collectToolCalls 按 tool_use_id 配对调用与结果 标出 pinned,无结果的不参与 ② fitState 造 Jev 要看的整段状态 结果换一行 note,六级缩身 ③ questionsFor 每个候选调用两个 noul 问题 "调用该留吗" / "结果该留吗" ④ batchCalls 按预算切批 30k − state − 20 ⑤ ask(并发) 每批一次请求 全文重发,答案合并 ⑥ decideCall 两个概率 → 三级瀑布决策 keep / drop_result / drop_call ⑦ applyDecisions 重建消息列表 空消息消失,未动的返回原对象 → CompactResult { messages, decisions, stats } stats 含逐类计数与请求数 贯穿全程的两条不变量 · pinned 的消息(第一条 + 最新 N 条)永不进入候选集,因此第一条里的用户约束永远安全。 · 结果永不先于调用消失:drop_call 时调用与结果一起走,drop_result 时两者同步截断,绝不产生"孤儿结果"。
图 3 · 七步流水线:前四步造请求,第五步并发问,后两步落决策

有个设计值得单独说:state 是「整段历史」,每批问题都要重复带一次。 为什么不把 state 切开放进各批?因为 Jev 需要看到全局才能判断「这条调用在后文里还被用到过吗」。代价是 state 越大部分越贵,所以 fitState 那一步才那么较真。

LAB 1

流水线剧场(端到端模拟)

用仓库 examples/demo.ts 里的真实剧本跑一遍。概率写死,为了演示稳定可复现。

场景:修复 src/parser.test.ts 的失败用例,约束是"不许动 legacy/、保持公开 API 向后兼容"。 参数:keepThreshold=0.5preserveRecentMessages=2。7 次工具调用全部进入候选集。

STEP 1 配对与「钉住」:把消息流变成可判定的调用单元

第一步不是压缩,而是建立可以被问问题的单位。原始 transcript 是一串消息,但工具调用是「两个消息里的两块内容」——assistant 消息里的 tool_use,和 user 消息里的 tool_result。它用 tool_use_id 把它们配成对,得到 ToolCall,带上 callIndex / resultIndex / resultChars / isError

// src/state.ts:50-56 · "钉住"的定义
export function isPinned(index: number, total: number, preserveRecentMessages: number): boolean {
  return index === 0 || index >= total - preserveRecentMessages;
}

为什么要 pin: 第一条消息永远是用户的初始任务描述——里面装着约束(「不要改 legacy/」、「保持 API 向后兼容」)。最新 N 条是当前推理的上下文,删了会断链。注意 pinnedcallIndex resultIndex 任一命中就成立——一次跨界的调用(调用在旧消息、结果在新消息)也不会被误删。

一个细节: 没有配到结果的调用不进候选集if (!found) continue)。结果还没回来,没什么可删的;而且删掉一个「正在飞」的调用会破坏 API 的 tool_use ↔ tool_result 配对约束。

STEP 2 造状态:把整段对话压缩成「给判断模型的证词」

这一步有两个目标,而且它们在拉扯:要信息完整(Jev 判断得准)、要塞得进 25k token(请求有上限)。解法是「先尽量完整,装不下才按固定顺序弃车保帅」。

实际发出的 state 长这样:

{
  "context": "A coding assistant conversation is being compacted to free context. history is the whole conversation so far, oldest first; tool outputs are replaced by a short result note and long texts may be abridged. ...",
  "goal": "Fix the failing parser test in the checkout service.\nDo not touch legacy/. Keep the public parser API backward compatible.",
  "history": [
    { "i": 0, "role": "user", "text": "Fix the failing parser test ..." },
    { "i": 2, "role": "assistant", "text": "",
      "tool_calls": [ { "id": "t1", "tool": "Glob",
                        "input": "{\"pattern\":\"src/**/*.ts\"}",
                        "result": "ok, 63 chars (omitted)" } ] }
  ]
}

三个设计点:

  • 工具结果只留统计,不留内容。 ok, 4213 chars (omitted)error, 187 chars (omitted)。因为要不要留这个结果,主要取决于它在对话里的角色(哪个文件、哪条命令、成没成功、多大),而不是内容细节。让 Jev 读全部结果内容既贵又会淹没关键信号。
  • goal 默认取最后 3 条用户提示,每条截到 500 字,并排除掉纯工具结果消息。等于自动注入一段「当前在干什么」——同一个 Read,在「修 parser」和「重构日志」两个目标下要不要留,答案完全不同。
  • 空消息不进 history。 省掉一堆 {"text":""} 的开销。

然后是八级缩身阶梯,顺序不是随便排的——从「损失最小的信息」开始牺牲:

顺序stage牺牲了什么
1full什么都没牺牲。工具输入最多 1000 字 × 文本全留 × 调用结构完整
2inputs<=200工具输入的细节。大文件的 Write/Edit 输入最占地方,但判断「这个调用是什么」只要路径和命令
3inputs<=60进一步:只剩调用的「名字与位置」
4texts abridged长文本变「头 400 字 + 省略标记 + 尾 150 字」,从最旧的非 pin 消息开始,最新的最后动
5old messages collapsed旧的纯文本消息整条变一行占位,只保留「这里曾有一大段话」
6old calls compacted旧的调用从结构化对象缩成一行:t12 Read file_path=src/a.ts → ok 480ch
7old messages left out丢消息但不丢调用:不含任何调用的旧消息整条移出
8old calls merged连续的多条「纯调用消息」折叠成一条 history 条目:信封只付一次,调用行一字不少
throw连折叠都塞不下 → 抛异常,交给上层回退。宁可不做,不做半成品

注意这里的取舍方向:先牺牲「细节」,最后才牺牲「消息的存在感」,而钉死的那几条消息永远在最后才被动。 这跟「按时间从旧到新一刀切」的粗暴做法完全不同——它是按信息密度从低到高地削

最妙的一处: 第 7、8 级是「丢消息但不丢调用」。因为每条 history 条目都带 JSON 信封开销,折叠意味着信封只付一次,而调用行一字不少。测试里 40 次调用被折成 3 条 history,而 40 个调用行完整保留。

装不下 25k 时,按这个顺序依次弃车 每一级都只在上一级没解决问题时才启用;每一级都从头重算,不会累积误差 full 工具输入最多 1000 字 × 文本全留 × 调用结构完整 inputs<=200 先砍工具输入的细节。大文件的 Write/Edit 输入最占地方,但判断"这个调用是什么"只要路径和命令。 inputs<=60 只剩调用的"名字与位置"。 texts abridged 长文本变"头 400 字 + [… N chars omitted …] + 尾 150 字",从最旧的非 pin 消息开始,最新的最后动。 collapsed 旧的纯文本消息整条变一行 [ … chars omitted … ],只保留"这里曾有一大段话"。 calls compacted 旧的调用从结构化对象缩成一行:t12 Read file_path=src/a.ts → ok 480ch
图 4 · 六级阶梯(前六级)。再装不下还有两级:把旧的"无调用消息"整条略去、把连续的旧"纯调用消息"折叠成一条

顺便,如果没显式给目标,它会把最近三条用户提问当成 goal 一起发给 Jev。这个细节很小但很关键:判断「这条调用还有用吗」,得先知道「我们到底在干什么」。

LAB 2

状态拟合台(fitState 移植)

拖动预算,看六级阶梯怎么依次启动、state 变成什么样。

stage
state tokens
history 条目
原始 transcript

这份合成 transcript 是怎么造的?为什么要造?

18 轮循环,每轮 5 条消息:一段约 900 字的助手说明、一次 Read(输入很小)、 一次 Edit输入很大——old_string/new_string 各 40 行都塞在 JSON 里)、以及两条工具结果。首条是带约束的用户需求。 一共 93 条消息、36 次工具调用。

之所以要合成而不是直接用 demo 里那个剧本: 真实剧本的工具结果虽然大,但结果内容根本不进 state(只换成一行 note), 所以它的 state 很小,压不出阶梯。这个细节本身说明了一件事: 状态预算是被"消息文本 + 工具输入"吃掉的;工具结果的体积只影响删不删它, 不影响问问题要花多少钱。而 Edit 的输入恰好是最典型的"占地方但有价值"的内容, 所以它是阶梯前两级(inputs<=200 / inputs<=60)唯一能触发的东西。

往左拖,你会依次看到 fullinputs<=200inputs<=60texts abridgedold messages collapsedold calls compactedold messages left outold calls merged;再往左到头就是那个 too large 异常(灰色错误态)。

STEP 3 提问:两个问题,措辞本身就是提示词工程

// src/compact.ts:56-67
export function questionsFor(call: ToolCall): JevQuestions {
  return {
    [`call_${call.id}`]: {
      type: 'noul',
      instructions: `Tool call ${call.id} (${call.tool}) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next`,
    },
    [`result_${call.id}`]: {
      type: 'noul',
      instructions: `The full output of tool call ${call.id} (${call.tool}, ${call.resultChars} chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do`,
    },
  };
}

两句大白话,就是实际发出去的两个问题:

工具调用 t7(Read)应该留在历史里:知道「当时发起过这个调用、输入是什么」,对助手接下来的动作仍然重要。

工具调用 t7(Read,3204 字符)的完整输出应该逐字留在历史里:助手仍然需要它的内容,重跑这个工具无法替代。

两个问题问的是两件正交的事,这是整套设计里最值得学的一招:

  • call_* 调用本身该留吗 —— 问的是历史痕迹的价值:「知道发生过这个调用、以及它的输入是什么,对助手接下来做的事还有意义吗?」典型高价值场景:助手曾确认过 legacy/parser.ts 与故障无关,这个「查过」的记录防止它几轮后再去查一遍。
  • result_ 结果该逐字留吗 —— 问的是内容的不可替代性:「助手还需要这些内容吗,而且重跑一次工具拿不到?」那句话里 re-running the tool would not do* 是关键约束:一个文件内容重读就有了;但一次已经过去的状态(某次测试在某个提交上的输出)重跑未必能复现。

为什么不分两次请求: 因为 Jev 的问题并行、互不可见。两个问题是独立判断,不会互相污染(不像让一个 LLM 连着回答会有上下文效应)。所以它们可以塞进同一个请求,代价只是几个 token。

STEP 4 分批:一个把预算算错就会崩的地方

// src/compact.ts:73-99
const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS; // 30000 - state - 20
...
for (const call of calls) {
  const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
  if (current.length > 0 && currentTokens + tokens > budget) { batches.push(current); current = []; currentTokens = 0; }
  if (current.length === 0 && tokens > budget) {
    throw new Error(`state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`);
  }
  current.push(call); currentTokens += tokens;
}

三条规则,缺一不可:

  1. 一次调用的问题对绝不拆开。 否则可能拿到 call_ 却没有 result_,决策就残缺了。
  2. 批与批之间不留空档。 current.length > 0 保证「装不下就立刻封批」,「单个超预算」由第 3 条兜底。
  3. 单个问题都塞不下就直接抛。 不静默丢问题。

用源码里的 estimateTokens 实跑一遍:一个 Read 调用的问题对约 124 token。所以当 state 吃满 25k 时,每批预算 30000 − 25000 − 20 = 4980,即每批约 40 个调用(80 个问题)

state token每批容量200 次调用需要的请求数总输入 token
25 000405150 000(≈ $0.006)
20 00099390 000
29 9000抛异常回退官方摘要

STEP 5 并发提问与答案合并:全文重发是刻意的取舍

// src/compact.ts:117-133, 271-279
const answered = await Promise.all(
  batches.map((batch) => askBatch(asker, state.state, batch)),
);
for (const map of answered) for (const [id, answer] of map) answers.set(id, answer);

每一批都重发同一份完整 state。这不是偷懒:

方案换来什么付出什么
全文重发(本项目)无状态:不需要维护会话 ID、不需要记住「上一批问到哪」、失败整批重试即可;每批判断题看到同样的证据,答案不会因批次而异输入 token 随批数线性增长;历史接近上限时「每问几十个问题就付一次 25k」
增量续问(假设 API 支持)省 token需要状态管理、幂等、断线续传;而 Jev 的 API 本身就是「一次调用一份 state」的形态

作者自己写进 README 的风险:「The full state is repeated with every request, so a history near the state ceiling costs one request per handful of questions.」 换句话说:会话越长,压缩越贵。提高 maxStateTokens 会挤掉问题空间;降低它又让 state 变糙、判断变差。25k / 30k 这两个数就是在这个张力下调出来的。

STEP 6 决策:三级瀑布与它的语义

整个压缩策略就这 15 行:

// src/compact.ts:101-115
export function decideCall(call, answer, options): CallDecision {
  const base = { id: call.id, tool: call.tool, ...answer };
  if (call.pinned) return { ...base, action: 'keep',        reason: 'pinned' };
  if (answer.keepResult >= options.keepThreshold) return { ...base, action: 'keep',        reason: 'kept' };
  if (answer.keepCall   >= options.keepThreshold) return { ...base, action: 'drop_result', reason: 'result_dropped' };
  return                                                { ...base, action: 'drop_call',   reason: 'call_dropped' };
}
钉死? ──── 是 ────> keep          reason: pinned(永不入候选)
   │否
结果概率 ≥ 0.5 ? ──是──> keep          调用 + 结果逐字保留
   │否
调用概率 ≥ 0.5 ? ──是──> drop_result   调用留下,结果截成前 300 字 + 说明
   │否
                └──────> drop_call     调用与结果一起删

为什么 keepResult 先判? 因为「结果要留」是一个更强的条件:结果要留在逻辑上蕴含调用要留(不可能留一个没有调用的结果)。先判强条件,剩下的空间才交给「只留调用」这一档。

易错点: 判定顺序不是「先看调用再看结果」,而是先看结果。如果反过来写,一个 keepCall=0.9, keepResult=0.1 的调用会被判成 keep,中间那档就永远走不到了。这 15 行的顺序是经过推理的。

阈值 0.5 意味着什么? 不是「大概率要留」,而是「只要比抛硬币更可能有用就留」——整体偏保守。想更激进地压,抬到 0.7;想更安全,降到 0.3。

另外两个容易漏的边界:

  • 钉死的调用直接返回 keep,连问都不问,理由记作 pinned
  • 没拿到答案的调用默认 keep(概率按 1 处理)。宁可留着,不可误删——这个默认值本身就体现了这份代码的性格。
pinned? action: keep reason: pinned 永不入候选 keepResult ≥ 0.5 ? action: keep 调用 + 结果逐字保留 keepCall ≥ 0.5 ? action: drop_result 调用保留,结果截成前 300 字 + 说明 action: drop_call  → 调用与结果一起删 为什么 keepResult 先判? 因为"结果要留"是一个更强的条件: 结果要留在逻辑上蕴含调用要留(不可能留一个 没有调用的结果)。先判强条件,剩下的空间才 交给"只留调用"这一档。 阈值 0.5 意味着什么? 不是"大概率要留",而是"只要比抛硬币更可能 有用就留"——整体偏保守。想更激进地压,把它 抬到 0.7;想更安全,降到 0.3。
图 5 · 三级瀑布:默认阈值下,"不确定"一律倾向保留
LAB 3

阈值决策台(decideCall + applyDecisions 移植)

调四个数字,看同一个工具调用落进哪一档、消息最终长什么样。

action
reason
结果字符数
占原文

↓ applyDecisions 之后 tool_result.text 的实际内容

tool_result.text

STEP 7 重建:对象同一性不是优化,是协议

drop_result 的 4000 字工具输出会变成什么样:

// src/compact.ts:135-141
function truncatedResultText(text: string, isError: boolean, headChars: number): string {
  if (text.length <= headChars + 120) return text;   // 短结果不值得截,原样留着
  const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
  return `${head}[fast-jev-compaction truncated ${text.length - headChars} chars of this tool result${
    isError ? ' (error)' : ''
  }; re-run the tool if needed]`;
}

三个细节:

  • 「前 300 字」是有信息量的。 文件开头的 import 列表、报错的第一行、命令的头部输出——头部密度远高于尾部。所以留头不留尾(truncateHeadChars,默认 300;设 0 就只留说明)。
  • 说明里写明数字与「可以重跑」,助手看到它知道「这里删了 3700 字」,而不是面对一个静默变短的结果发懵。
  • 短结果直接放过。 原文只有 400 字时整条保留;删 100 字换来一条说明是负收益。

重建里有最漂亮的一处:

// src/compact.ts:161-168
for (const message of messages) {
  const touched =
    message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
    (message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
  if (!touched) {
    kept.push(message);      // ← 同一个对象引用,不是拷贝
    continue;
  }
  ...

这段在库层面只是「少一次拷贝」,但在插件层面是功能性的。Claude Code 给每条 SessionMessage 盖了一个不透明的 handle,类型声明里写得很清楚:

「a message kept with its handle is the engine's own, whole; one without is read as built.」

带 handle 的原对象 = 引擎自己的,原封不动;没有 handle = 这是插件新造的,按新内容处理。

于是适配器用两个 Map 把「输入对象 → 引擎对象」映射回来:命中的直接返回引擎原对象(带 handle),重建过的就构造一个新的(无 handle)。对象同一性在这里就是通信协议。

所以代码里到处是 tool === message.toolUses[index] 这种引用相等的比较,看着啰嗦,其实是在省掉一整轮无谓的重建。两个收尾的不变量:

  1. 内容全丢的消息整条移除;但条件是 message.text.trim().length === 0——只要还有一句人话,消息就留着,哪怕工具块都清空了。
  2. 「看起来被碰过但实际没变」也返回原对象:重建后如果逐个元素与原来全等,仍然返回原引用,把对象同一性最大化。

还有一条判断特别隐蔽:如果一条消息里的工具调用全被判了 drop_call,那整条消息直接跳过。少写这一句,「结果减了多少」就算不对——因为空壳消息还在占位置。

它怎么数 token(全篇预算的地基)

所有预算都得先知道「有多大」,但 Jev 的计费口径你本地没有分词器。这个项目自己写了个估算函数,规则简单到可以背下来:

  • 连续字母:1 个 token,之后每多 6 个字母加 1 个。
  • 连续数字:长度的一半。
  • 其他任何符号:每个 0.9。

写成正则是 /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g

有意思的是源码里那句注释:这个估法在真实对话上比 Jev 报的实际用量高 2% 到 18%,而「字符数除以固定值」那种土办法,在这种塞满 JSON 的 state 上会低估到 40%。 宁可高估不可低估——估低了,请求就会超预算被拒。

顺带一个对中文用户的实际影响:「其他符号」包含了汉字,所以每个汉字算 0.9 个 token。对一个中文为主的对话,这个数字是偏低的(真实分词器下中文往往更贵)。所以别拿它去算账单,它只是个「会不会超预算」的粗尺子。

LAB 4

分批计算器

拖动看看 state 大小和调用数怎么共同决定请求数。

问题对 / 调用
124
每批容量
40
请求数
5
总输入 token

05 交互实验室

上面四个实验台已经把源码搬到浏览器里了,它们不是示意图——驱动它们的 estimateTokensfitStatecollectToolCallsdecideCallapplyDecisions,都是从仓库源码逐行移植过来的 JavaScript:

实验台在本文的位置能玩什么
流水线剧场04 开头examples/demo.ts 的真实剧本端到端跑一遍七步,逐条看每个调用落进哪个分支
状态拟合台STEP 2 末尾拖动 maxStateTokens,看八级阶梯怎么一级级启动、在哪一级抛异常
阈值决策台STEP 6 末尾keepCall / keepResult / keepThreshold,看同一条调用在四个数字下变成 keep / drop_result / drop_call
分批计算器STEP 4 之后改 state 预算与调用数,算需要几次请求、总共多少输入 token

这四个实验台只在网页版可用。点右下角「复制到公众号」时,它们会被自动换成一句文字说明,不会把一堆点不动的控件带过去。

不跑实验台也能看懂的读数

四个台子的输入都是仓库里可复现的固定剧本,所以结果也能写成静态表。下面是它们在网页版里的默认读数。

LAB 1 · 状态拟合台。 这份合成 transcript 的满状态是 12 861 token;把 maxStateTokens 从上往下拖,八级阶梯依次启动,最后一级是抛异常:

  • 满状态:stage=fulltokens=12861、history 57 条。
  • 预算 9 600:tokens=8397stage=inputs<=200牺牲工具输入、保住文本)。
  • 预算 130 附近:抛 history too large for Jev (~1242 tokens after truncation, limit 130)

这份 transcript 是合成出来的:18 轮 × 5 条 = 93 条消息、36 次工具调用,每轮含一段约 900 字的助手说明、一次 Read(输入很小)、一次 Edit输入很大——old_string / new_string 各 40 行塞在 JSON 里)、两条工具结果。为什么要合成:真实剧本的工具结果虽然大,但结果内容根本不进 state(只换成一行 note),state 很小、压不出阶梯。这本身说明了一件事——状态预算是被「消息文本 + 工具输入」吃掉的,工具结果的体积只影响「删不删它」,不影响「问问题要花多少钱」。Edit 的输入恰好是阶梯前两级唯一能触发的东西。

LAB 2 · 阈值决策台。 同一条 Bash 跑 vitest 的调用(结果 2443 字、isError=true),只改四个数字:

keepCallkeepResultkeepThresholdtruncateHeadCharsaction结果字符数
0.900.700.50300keep2443(不变)
0.900.200.50300drop_result398(前 300 字 + 97 字说明)
0.100.200.50300drop_call0(调用与结果一起消失)
0.900.200.500drop_result97(只剩一行说明)

drop_result 留下的头部是有意义的:FAIL src/parser.test.ts 这一行还在,助手就知道「当时失败了、失败在哪」。

LAB 3 · 分批计算器。 见「STEP 4」那张表:预算 20 000 时每批能装 99 个调用、200 次调用只要 3 次请求;把预算顶到 29 900 反而每批装 0 个,直接抛异常回退。

LAB 4 · 流水线剧场。 仓库 examples/demo.ts 的真实剧本——修 src/parser.test.ts 的失败用例,约束是「不许动 legacy/、保持公开 API 向后兼容」,preserveRecentMessages=2

调用工具keepCallkeepResult决策
t1Glob0.820.55keep
t2Read legacy/parser.ts0.240.12drop_call
t3Read src/parser.ts0.880.91keep
t4Bash vitest(FAIL)0.860.93keep
t5Edit src/parser.ts0.900.80keep
t6Bash vitest(PASS)0.900.24drop_result
t7Bash npm test0.940.20drop_result

结果是 messages 21 → 19、字符数减少 52%、kept / resultsDropped / callsDropped = 4 / 2 / 1、state ~1131 token、1 次请求。52% 高于 minReductionRatio(0.25),所以这次压缩会被采纳、顶替官方摘要。两个值得注意的点:被 drop_call 删掉的是一次「读了无关文件」的调用,但「它无关」这个结论写在助手的文本里,完好无损——这正是「文本永不删」的价值;而两次 drop_result 其实一点字符都没省下来(结果只有 80 多字,headChars + 120 都超不过,于是整条放过),压缩主要来自那次 drop_call

06 插件侧:钩子、手柄与三层回退

插件挂在两个钩子上,各管一件事:

钩子触发时机它做了什么
session.compact会话即将被压缩时(/compact、自动压缩、其他插件触发)把 Jev 方案跑一遍,返回 { messages } 顶替掉内置摘要;任何一步出错或收益不够就 return next(event) 交给官方摘要
turn.complete每一轮对话结束时$.session.usage(),上下文占比到 compactAtPercent(默认 60%)就主动触发一次压缩

自动触发那半段有一个跨事件存活的并发锁

export const register: Register = (on: On, options: PluginOptions) => {
  const configured = resolveHookConfig(options);
  let compacting = false;                       // 跨事件存活,就是一个并发锁

  on('turn.complete', async ($, event, next) => {
    if (compacting) return next(event);         // 上一轮压缩还没结束,不叠加
    try {
      const { context } = await $.session.usage();
      if ((context.percent ?? 0) < configured.compactAtPercent) return next(event);
      compacting = true;
      await $.session.compact();                // 主动触发,会再走一遍 session.compact
    } catch (error) {
      $.ui.log(`auto-compact skipped (...)`);   // 自动压缩失败只记一行,不打扰用户
    } finally {
      compacting = false;
    }
    return next(event);
  });
};

为什么要锁:turn.complete 主动调 $.session.compact(),而压缩本身又会派发 session.compact 事件。如果压缩期间又结束了一轮对话,没有守卫就会出现两个压缩同时在跑(各自打一遍 Jev、各自重建历史),结果取决于谁后写——典型的竞态。而且 compactingregister() 闭包里的变量,不是模块级单例,所以每个插件实例有自己的锁。

三层回退:一个「永不比现状更差」的设计

on('session.compact', async ($, event, next) => {
  try {
    const config = { ...configured, apiKey: await getApiKey($, configured) };
    const { result, messages } = await compactSession(event.messages, config, httpFetch);
    for (const line of decisionLogLines(result)) $.ui.log(line);   // 逐条决策写进日志

    if (reductionRatio(result) < config.minReductionRatio) {       // ← 回退第二层:收益不够
      notify($, `fallback to built-in summary (below ${percent(config.minReductionRatio)} minimum: ${summarize(result)})`);
      return next(event);
    }
    notify($, `kept ${messages.length}/${event.messages.length} messages, no summary (${summarize(result)})`);
    return { messages };                                            // ← 成功:顶替内置摘要
  } catch (error) {                                                 // ← 回退第一层:任何异常
    notify($, `fallback to built-in summary (${error instanceof Error ? error.message : String(error)})`);
    return next(event);
  }
});
回退层触发条件为什么这么设
① 异常回退TYPESAFE_API_KEY、网络失败、answers 缺失、答案不是数字、state 塞不进预算(too large)、单批放不下一个问题错误信息都写进 toast,用户能看见原因。库的原则是该抛就抛,让调用方决定——库本身从不自己兜底
② 收益回退字符压缩率 < minReductionRatio(默认 0.25)短会话本来就没多少可删的,用「删掉几条工具结果但不给摘要」去换掉官方摘要可能是净损失
③ 无候选没有任何非 pinned 的工具调用不调 API、不报错,直接走官方摘要(requests: 0

第三层「放弃」是良性的:如果压根没有候选(比如整段对话都在钉死的范围里),它一个请求都不发,直接返回原样。

可观测性。 成功时 toast 长这样:

fast-jev-compaction: kept 18/21 messages, no summary
  (62% reduction; 4 kept, 3 results truncated;
   state ~18420 tokens (inputs<=200) in 2 request(s))

括号里信息密度极高:压缩率、逐类决策计数、state 大小、命中了哪一级阶梯、几次请求。另外日志里会写一行:

decisions: t1:Glob:keep/call=0.82/result=0.55
           t2:Read:drop_result/call=0.71/result=0.12
           t3:Read:keep/call=0.88/result=0.91

这套日志设计的意义是:事后你能量化复盘「哪些被误删了」。 大多数压缩方案根本给不出这个——它只会告诉你「已总结」。

一个工程细节:决策日志按 4096 字符预算切行并标上 (1/3) 这样的序号。一次 200 调用的会话会产生接近 8KB 的决策日志,而 UI 单行有长度上限。这种「早期访问 API 的隐形约束」在真实集成里到处都是——纯库作者容易忽略。

找 key 的三级查找

async function getApiKey($, config: HookConfig): Promise<string | undefined> {
  if (config.apiKey) return config.apiKey;              // ① 插件 userConfig(敏感字段,安装时填)
  const fromEnv = await $.env.get('TYPESAFE_API_KEY');  // ② 环境变量(推荐给开发用)
  if (fromEnv) return fromEnv;
  const settings = await $.settings.read();             // ③ settings.json 的 env 块
  const env = settings['env'];
  if (env && typeof env === 'object') {
    const value = (env as Record<string, unknown>)['TYPESAFE_API_KEY'];
    if (typeof value === 'string' && value) return value;
  }
  return undefined;                                     // 都没有 → compactSession 抛错 → 回退
}

README 明确建议用环境变量而不是插件选项——插件选项会被写进配置文件,环境变量不会。库里也有一句硬话:「Never commit the key or put it in a source file.」

07 参数、成本与延迟实算

参数默认作用与调参直觉
keepThreshold0.5压缩的总开关。抬高=更激进地删;降低=更保守。唯一你会经常动的参数
preserveRecentMessages6最新多少条永不碰。太小 → 当前推理链断裂;太大 → 可压缩比例变小。「能省多少」的天花板由它决定
maxStateTokens25 000状态预算。太大挤掉问题空间导致请求数暴涨,太小让判断变糙
maxRequestTokens30 000单次请求上限,必须低于 Jev 的 32k 硬限制
truncateHeadChars300drop_result 时留多少原文。设 0 只留一行说明——最省,但失去「这个文件大概长什么样」的线索
goal最后 3 条用户提示显式指定「当前任务」。多任务长会话里显式指定比自动推断准得多
compactAtPercent(插件专属)60上下文占用到多少百分比自动触发。官方自动压缩一般更晚(80%+),提前到 60% 是为了避免压到一半没空间
minReductionRatio(插件专属)0.25低于这个收益就放弃。防的是「用一次网络往返换来 3% 的收益」

成本与延迟(默认参数、state 吃满做上界估算):

每次请求30 000 token 输入(25k state + 问题 + 信封开销)
每批可问约 40 个调用 → 80 个问题
200 次调用的会话5 次请求 = 150 000 输入 token
Jev 价格$0.042 / 百万输入 token(输出免费)
一次压缩的 API 成本约 $0.006——不到一分钱

延迟上,5 次请求并发(Promise.all),而 Jev 官方标称端到端 70ms~500ms,所以整体大概率在一秒以内stats.ms 也在结果里,可以自己量。

要小心的一个洞: 成本随「会话长度 × 调用次数」平方级增长——state 越长,每批装的问题越少,请求越多,而且每次都要重发长 state。极端情况:state 吃满 25k、候选调用 400 个 → 10 次请求 × 30k = 300k token ≈ $0.013,仍然是可忽略的量级。真正的成本不是钱是延迟——10 次并发请求串起来的等待,在自动压缩路径上用户能感觉到。

08 局限、风险与几个源码级发现

作者自己在 README 里写下的局限

  1. 只有工具调用和结果是候选,文本永不删。 输出里的文本消息既不删也不缩短(只在送给 Jev 的 state 里被截断)。所以「全是长文本、没有工具调用」的会话它一点也压不动。
  2. token 是估算,不是分词器。
  3. 校准在请求级,概率不是证明。 原话:「a probability is not a proof that a result is safe to delete」。缓解手段是「助手随时可以重跑工具」。
  4. 全文重发导致长会话请求数线性增长。

换成使用者视角,这几条意味着:

  • 不许删的调用,它删不了。 你没给它「这条绝对不能动」的接口,只有首条和最近 6 条是自动钉死的。真有必须保全的中间调用,得靠把 preserveRecentMessages 调大来覆盖。
  • 中文对话的预算会被低估。 汉字按 0.9 token 算,中文占比高的时候实际开销会比估算更贵,可能提前撞上「装不下」。
  • 判断质量取决于问题写得好不好。 那两句话本身就是提示词——它们陈述得越具体,Jev 的概率越有意义。想让它做得更好,改那两句比调阈值管用。
  • 它不解决「该记什么」。 它只决定「什么该留」。真正需要跨轮次携带的关键约束,还是得写进系统提示或项目文件里。

读源码时发现的七件事

  1. 中文会话会被低估 token。 分词估算的正则是 /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g,每个「非字母数字非空白」的字符(也就是每个汉字、每个标点)统统算 0.9 token。实测 160 字中文得 0.91 token/字,但真实 BPE 分词器对汉字通常 ≥1 token/字。作者的校准是针对英文 transcript 做的,中文会话下这个估算会偏小,可能导致 state 实际超出 maxStateTokens、或者某批请求真的撞上 Jev 的 32k 硬限制。实践建议:用中文会话时把 maxStateTokens 从 25000 下调到 20000 左右。(这是我的推断,不是作者的说明。)
  2. 没有任何答案的调用,默认是「全保留」。 兜底是 answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }——「问不到答案」就保持原样,方向正确。好在答案解析对「答案存在但格式不对」会抛异常,所以只有「整个问题没被回答」才会静默走这条兜底。
  3. drop_result 会同时改两处文本。 assistant 消息里的 tool_use.text(Claude Code 把调用结果也贴在了调用块上)和 user 消息里的 tool_result.text 会被同步截断成同一个字符串。如果只改一边,转录里同一个工具的输出会出现两个不一致的版本。
  4. 「离开的」消息是跳过而不是重建。 那条 continue 意味着被删除的消息从数组里彻底消失,不留占位。有意为之:留一个「[已压缩]」占位符反而浪费 token 并干扰模型。代价是消息索引会变——任何外部的「第 N 条消息」引用都失效了。
  5. stats.charsBefore 把「不可删的部分」也算进去了。 它统计整条消息的全部内容,包括永远不可能被删的 text。所以 reductionRatio 是一个保守的(偏低的)压缩率。对 minReductionRatio 这个门槛来说这是好事:它不会因为分母里塞了一堆不可删的文本而虚高。
  6. REQUEST_OVERHEAD_TOKENS = 20 是个「诚实的偷懒」。 请求信封(model 字段、JSON 括号、state / questions 键名)其实不止 20 token。常数 20 是「少算一点、留一点安全边际」的写法。真正的安全边际来自 maxRequestTokens=30000 相对 32k 硬限制的那 2000 token。
  7. 三个「看起来能改但别随便改」的地方:
  • preserveRecentMessages 只保护「消息」,不保护「调用」。 pin 区内的调用完全不参与判断——如果你在最近 6 条里读了 10 个大文件,它们全都不会进候选,压缩率会很难看。
  • Jev 的提问措辞是英文写死的。 没有国际化。想换措辞做 A/B 测试的话,记住它同时决定了 statecontext 那段旁白——两者要配套改。
  • estimateTokensbatchCallsfitState 里各算一遍,用的是同一个函数。 两处预算口径天然一致。如果给某一处换成真实分词器,另一处也必须换,否则会出现「state 说装得下、实际装不下」。

09 和其它压缩方案的正面比较

方案机制信息损失可验证性成本最适合
滑动窗口直接丢掉最旧的 N 条整条整条地丢,包括用户约束无(丢什么是写死的)短会话、无状态单次问答
LLM 摘要让模型把旧对话改写成一段话不可控、不可逆;细节被自然语言重编码时蒸发差(无法得知漏了什么)一次大模型调用,通常贵且慢语义连贯性比细节保真更重要时
检索式记忆(RAG)旧内容向量化入库,按当前 query 召回召回失败就完全丢失;需要额外基础设施中(能看见召回了什么)嵌入 + 向量库 + 每轮检索跨会话的长期知识、文档问答
结构化状态槽把进展写进一个外部 JSON(待办、已改文件)只保留你预设的字段好(字段就是契约)零(但需要精心设计 schema)流程固定的长任务
本项目(Jev 引导的删除)逐调用判断「删 / 截断 / 留」,从不改写只损失可重放的工具内容;文本零损失(每个决定带概率 + 理由,可逐条审阅)一次会话几美分内,延迟 1 秒级工具调用密集的编码 Agent 会话

最该记住的对比: 把它和「LLM 摘要」放在一起看,分界线很清楚——摘要解决的是「我不知道什么重要,你帮我挑」;删除解决的是「我知道什么可重放,你帮我判」。 前者把决策权交给模型且不可逆;后者把决策权变成阈值 + 概率,而且删错的东西有原路可回

10 可迁移的三条工程思想

一、「判断」和「生成」分开

把一个需要「输出一段话」的任务,改写成若干「回答一个是非题」的任务,会同时得到三样东西:可缓存、可并行、可阈值化

有意思的是这件事不需要 Jev 也能做——用一个普通 LLM 问「是 / 否」并要求它只输出一个数字也能近似实现(社区里就有人用 1B 小模型复刻了类似的并行判断)。这个仓库真正的贡献不是「用了 Jev」,而是把压缩问题拆成了正确的判断单元:两个正交的是非题 + 三级动作。

二、不可逆操作要有「边界对象」

这套系统明确划了一条线:可重放的(工具输出)可以删,不可重放的(人类与助手的文本)永不碰。 有了这条线,「自动删除」才敢上线——因为最坏情况是可恢复的。

推广开:任何自动化的破坏性操作,都应该先问「这个东西丢了能不能拿回来」,能拿回来的进自动化白名单,拿不回来的走人工确认。truncateHeadChars 保留前 300 字 + 写明「可以重跑」,就是这个思想的微观实现。

三、渐进降级,而不是一次降到底

八级缩身阶梯的顺序本身就是一份价值排序声明:工具输入的细节 < 旧消息的原文 < 消息的结构 < 消息的存在。系统按这个顺序一级一级地牺牲,只有前一级不够时才动下一级。

对比「直接把 state 截断到 25k」的做法:后者会把对话截成两半,中间断掉,而阶梯保证了无论预算多紧张,「最近发生了什么、有哪些工具调用」这两个骨架永远在。这是所有降级策略都该学的:先定义什么绝对不能丢,再决定丢的顺序。

附赠:失败路径的设计比成功路径更重要。 三层回退加上「库只抛错、调用方决定」的分工,让这个插件最坏结果等于没有它。这是所有「增强型中间件」该有的自觉。一个小证据:compact() 里对「没拿到答案」的调用默认判成 keepResult=1——往「保留」的方向兜底,而不是往「删」的方向。

11 附录:文件清单与跑法

仓库与版本

github.com/tamaratran/fast-jev-compaction · MIT · 库 v0.2.0 / 插件 v0.3.0 · TypeScript(ESM,Node ≥ 18)。本文引用的行号对应主分支提交 e3f262a

作为库使用

npm install fast-jev-compaction
export TYPESAFE_API_KEY=...
import { compactMessages, reductionRatio, type Message } from 'fast-jev-compaction';

const result = await compactMessages(transcript, { preserveRecentMessages: 4 });
console.log(result.messages, result.decisions, result.stats);

if (reductionRatio(result) < 0.25) {
  // 不值得:保留原 transcript,或者走摘要
}

换成自己的传输层(库的核心解耦点)

import { compact, buildJevRequest, parseJevResponse, type JevAsker } from 'fast-jev-compaction';

const myAsker: JevAsker = {
  async ask(state, questions) {
    const req = buildJevRequest({ apiKey: myKey, model: 'jev-latest' }, state, questions);
    const res = await myHttpClient.post(req.url, req.body, req.headers);
    return parseJevResponse(res.status, res.ok, res.text);
  },
};

await compact(transcript, myAsker, { keepThreshold: 0.6 });

整个库不依赖任何模型 SDK,只要求你有一个 ask(state, questions) 形式的函数,所以本地跑测试完全不需要 API Key——插件里就带了一个假 asker 的测试。

装成 Claude Code 插件

# 1. 打开 function hooks 并给 key(写进 ~/.claude/settings.json 的 env 块)
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
export TYPESAFE_API_KEY="<your key>"

# 2. 把仓库当 marketplace 装插件
claude plugin marketplace add tamaratran/fast-jev-compaction
claude plugin install fast-jev-compaction@fast-jev-compaction

# 3. 重启 Claude Code 或 /reload-plugins
#    之后 /compact 与自动压缩都会走 Jev,toast 显示 kept N/M messages, no summary (...)
#    短会话会印 fallback to built-in summary (below 25% minimum: ...)

# 不想装的话,从 checkout 直接跑:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .

开发命令

命令做什么
npm install装 devDependencies(typescript / vitest / tsx)——没有运行时依赖
npm run typecheck库 + 钩子两套 tsconfig 一起查
npm testvitest,用 fakeJev完全不联网
npm run buildtsc 出 dist/
npm run demo唯一一次真实网络调用,需要 TYPESAFE_API_KEY
npm run validate:pluginclaude plugin validate 校验插件清单
demo/JevDemo/build.sh构建并启动 macOS 那个动画演示(仅限 macOS)

建议的阅读顺序

types.ts(先看数据结构)→ compact.tscompact()(主干七步)→ state.tsestimateTokens + fitState(最复杂的部分)→ compact.tsdecideCall(策略核心,15 行)→ hooks/fast-jev.tstoSessionMessages(最能体现宿主集成的技巧)→ 最后回头看 tests/,它是最好的规格说明。

仓库里还带了一个 examples/demo.ts 和一段可以直接跑的演示对话,是理解八级阶梯最快的方式:改一下 maxStateTokens,看它在第几级停下来。

最后回到那句话:证明模型能把对话总结得漂亮是一种本事,证明它知道哪些东西根本不该动,是另一种本事。 上下文压缩的真正难点从来不是「写得多好」,而是「忍住不写」。

Jev / System One 的公开信息来自 TypeSafe 官方介绍与公开报道;本文对设计动机的解读属个人分析,不是作者说明。交互实验室里的 estimateTokens / fitState / decideCall / applyDecisions 均为源码的 JavaScript 移植,行为尽量逐字对齐。