Leafstory/dsh-context-checkpoint ↗★ 1
@dsh-external/context-checkpoint
DSH 上下文检查点:每步注入真实占用条、模型可显式预约 checkpoint+压缩、压缩后在回合边界执行并自动继续同一任务 适合使用长上下文任务、需解决模型后期注意力漂移问题的用户。
설치
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:Leafstory/dsh-context-checkpoint@dsh-external/context-checkpoint
把「达限 → 总结落盘 → 自动压缩 → 压缩后把总结重新注入为静态上下文」做成一条闭环。
背景:为了解决什么
为的是 DeepSeek V4.1 Flash 在 DSH 长上下文里的稳定性问题。会话一长,就出现这些现象:
- 否定自己先前的正确结论 —— 明明已经定下并验证过的事,被重新推翻;
- 丢失关键节点 —— 走到哪一步、还差什么,说不清;
- 遗漏问题 —— 用户提过的约束与待办被漏掉;
- 幻觉率随上下文变长而升高 —— 于是要花大量时间回头纠正。
同时我们注意到一个可以利用的特性:它在每轮对话的开头注意力最高、思考也最展开。 换句话说,关键状态放在上下文的开头,是模型最容易真正读进去的位置;埋在几十万 token 的历史里则相反。
所以做法就定下来了:自动总结 → 压缩 → 把总结注入回上下文开头 → 继续同一个任务。 每到一个交付点把项目现状落盘;压缩交给 DSH 原生引擎;压缩后插件把那份总结重新注入成 静态上下文(永远位于提示词前部)。模型因此在"注意力最高的位置"拿到当前事实, 而不是依赖回忆,也不必在"上下文快满了要不要重开会话"之间反复权衡 —— 长期任务可以一直跑下去。
这是工程手段,不是模型修复:插件的职责只是把关键状态反复放到最有注意力的位置。
闭环的四步
| 步 | 由谁做 | 说明 |
|---|---|---|
| ① 达限 | 越线提醒(本插件)/ DSH 原生阈值 / 用户本人明说 | 跨档位时本插件提醒一次落盘;占用 ≥ 窗口 50% 是触发门槛,但用户明确要求总结/开始项目时门槛让路(D-006) |
| ② 总结 | 模型 + context_compact + skill | 模型把项目状态写进 important_view.-.md |
| ③ 压缩 | DSH 压缩引擎(本插件触发:compactIfNeeded) | 触发点 = agent/pre-step 步骤边界,用 'context-overflow' 绕过阈值 |
| ④ 注入 | 本插件的系统提示词段 | 每代读一次检查点文件,内容作为静态上下文注入;跨代(压缩发生)自动换新 |
第 ④ 步是本插件的核心:important_view 的正文因此长期存在于 prompt 里,
不依赖模型记住、也不受自动压缩摘要取舍的影响。
两个工具
| 工具 | 作用 |
|---|---|
context_status | 按需读精确占用(已用 token / 窗口 / 距安全线 / 待执行数 / 压缩执行入口探针 / 检查点文件探测) |
context_compact | 落盘锚点 + 预约:记录交付点,把压缩挂到下一个步骤边界。它本身不执行压缩 |
占用数字只在模型主动调用 context_status 时给出,不进提示词段 ——
提示词段里只要出现动态值,整段 prompt 缓存就会失效(代价是每天上百美元的重复计费)。
③ 压缩是怎么被触发的
触发点是 agent/pre-step(步骤边界),配合 'context-overflow' 这条分支 ——
它不比阈值、也不需要 agent 空闲。这一点是关键,因为空闲窗口根本等不到:
等空闲再压是走不通的:模型调用 context_compact 之后继续在同一回合里干活,
空闲窗口永远不出现,预约的压缩一直不执行,直到请求被服务端以
This model's maximum context length is 1048576 tokens. However, you requested 1048831 tokens
(655831 in the messages, 393000 in the completion). code: CONTEXT_WINDOW_EXCEEDED, status 400
拒绝,才由 DSH 的 overflow 兜底路径勉强压了一次。另一种做法 compactNow() 同样要求空闲
(它内部就是 agent.runMaintenance()),回合内调用必被包成 ManualCompactionError('busy')。
实际做法:读 DSH 自己的代码找到那条不需要空闲、也不比阈值的分支 ——
// dsh-compaction-basic/lib/index.js:886-893
if (trigger === "context-overflow") { // 不走 threshold 判定
if (prune !== void 0) { prune.pruneSession(agent.session); measurement = meter.measure(agent.session) }
const range = selectCompactableRange(agent.session, measurement, 0)
if (range === null) return null
return this.compactRegion(range.start, range.end, agent, signal)
}
于是插件挂 agent/pre-step(DSH 自己也是在这个事件里做压力压缩的):
- 不依赖阈值(
thresholdRatio 0.8 × contextWindow在本机因maxTokens占掉输入预算而永远够不到); - 不依赖空闲(
compactIfNeeded没有runMaintenance包装); - 不依赖模型自觉停手(预约后的下一步就执行;若模型正好收尾,则由 idle 兜底 +
followup()唤醒)。
三道闸 + 一层豁免 + 一个显式绕行口:
| 闸 | 常量 | 作用 |
|---|---|---|
| 触发门槛 | MIN_TRIGGER_RATIO = 0.5 | 占用 "`)。 |
- 注入消息不算用户消息。 压缩摘要(
source.kind === 'compact')、AGENTS.md (agent-instructions)、技能目录(skill-catalog)各有自己的 kind,一律不算 —— 否则一次压缩 留下的摘要就可能把后面每一轮都变成"用户要求过"。 - 窗口为 0 必须真的等于关闭。 回归 5j 当场抓到过:写成
ageMs 判定"新代码是否真的在跑":看context_status有没有userIntentWindowMs/recentUserIntent`。
本机 2026-09-23 的 42,632 B 旧版(只有落盘闸)没有这两个字段 —— 注入成功 ≠ 代码生效, 改完代码必须重启 DSH(见文末)。
怎么证明第 ④ 步真的发生了(取证通道)
"总结有没有真的进提示词"不能靠感觉。三个候选通道里只有一个能用:
| 通道 | 含提示词正文? | 结论 |
|---|---|---|
request/header | ❌ 只有 config / tools 清单 | 正文不在这里(曾在此搜 important_view,一无所获) |
user/message src=compact | ❌ 是压缩摘要,不是提示词 | 只能当阴性对照 |
system/message | ✅ 完整落盘 system prompt 正文 | 唯一独立通道 |
取证两步(脚本已备好):
# ① 一次拿到"每一代提示词里装的是第几代检查点"
node scripts/list-events.mjs system/message --last 6 --grep "generation: 6"
# ② 阳性 / 阴性对照:同一个判别词,分别打在"压缩后的提示词"与"压缩摘要"上
node scripts/dump-event.mjs 5427 --grep "generation: 6" # 压缩后的 system/message → 应命中
node scripts/dump-event.mjs 5415 --grep "generation: 6" # 同一次的 compaction/summary → 应无命中
本机实测的生成序列(会话 57d4c0ff):
| 事件 | 段内 generation | 段内字节 |
|---|---|---|
#4608 | 2 | 19,363 |
#4677 / #4702 | 3 | 17,849 / 17,956 |
#5203 | 5 | 20,987 |
#5427 | 6 | 18,957 |
#5203 是压缩 #5188 之后新铸的提示词(段内 gen 5),#5427 是压缩 #5414 之后新铸的
(段内 gen 6 —— 就是 #5406 刚落盘的那份)。代次号在压缩处变了,且不是摘要污染,这条就是 ④ 的硬证据。
⚠️ 判别词必须逐个做阴性对照。实测被污染过的两个:Fast Resume、
userIntentWindowMs: 900000 —— 它们在压缩摘要里也各出现 2 次(摘要吸收了我自己写文件时的
工具调用参数)。干净的判别词例如 generation: N、检查点特有的小节名、或正文里只属于自己的那句话。
为什么插件不自己执行压缩
用 agent.whenIdle() → agent.runMaintenance() → ctx.compaction.compactNow()
在回合边界执行压缩,这个时机窗口赢不了:
compactNow()内部就是agent.runMaintenance(...),phase 不是 idle 时被包成ManualCompactionError('busy', 'manual compaction requires an idle agent with no waking queued work')(dsh-compaction-basic/lib/index.js:944-969);- turn 边界一开,phase 立刻变回
running;而followup()自己还会登记"待唤醒工作"。
实测报错:
ManualCompactionError: manual compaction requires an idle agent with no waking queued work
结论:插件绝不自行实现压缩算法(那是重写 DSH 带 8 小节 checkpoint 的逻辑), 但触发时机必须自己掌握 —— 具体做法见上一节。
平面差异(重要)
ctx.tokenMeter 与 ctx.compaction 只挂在 host 平面。dsh-web-app 的装配里
compaction-basic / command-compact 被显式 disable(本机已用 profile patch 把
compaction-basic 装回)。因此本插件的硬依赖只有
inject = ['tools', 'systemPrompt', 'llm'],那两个用 ctx.get() 软解析:
- 有计量、有压缩 → 全功能;
- 只有计量 → 能测不能压,工具会明确说明并给出
/compact与自动压缩的真实路径; - 两者都没有 → 如实降级,绝不编造百分比、绝不假装压缩过。
为什么
llm必须在inject里:readPressure/resolveWindow要用它解析窗口容量。 漏掉它的后果是context_compact一调用就抛cannot get property "llm" without inject—— 功能 2 直接不可用(真实发生过)。
开发约定(踩过的坑,别重犯)
1. 零依赖是硬要求
插件经 junction 暴露后,Node 按 realpath 解析依赖。插件目录没有 node_modules 时,
import '@deepseek-ai/dsh-llm' 会 ERR_MODULE_NOT_FOUND,而模块级 import 失败会让 apply
永不执行 → fiber 永久 pending → DSH 起不来。
所以:只用 node: 内置模块,createUserMessage / defineTool 在本文件内联。
2. 绝不直接访问未声明的服务
ctx.tokenMeter // ✗ 抛 cannot get property "tokenMeter" without inject
ctx.get('tokenMeter') // ✓ 服务不存在时返回 undefined
写法与 inject 声明必须匹配:写进 inject 会在缺该服务的平面上永久挂起;不写又直接属性访问会抛错。
唯一正确姿势是 ctx.get()。
3. WeakMap 的键必须是对象(本插件最致命的一次事故)
const routeBySession = new WeakMap()
routeBySession.set(options.sessionId, ...) // ✗ 字符串键 → Invalid value used as weak map key
它不在于装配阶段炸,而在首次 llm/stream 事件炸,所以换 dev_inject_plugin 还是
dev_install_package 都报同一个错、并毒化整个插件树(DSH 直接不可用)。
字符串键一律用 new Map()。
4. 所有注册都过 register() 包装
ctx.on 在被取消时返回布尔 true;原始值一旦进入 cordis 的 DisposableList,
weak.set(true, sn) 同样抛 WeakMap 错。register() 会校验返回值确实是 disposer,
不合法就抛一条指向本插件、可读的错误。
5. 装配有两条路径,不要同时用
| 路径 | 入口 | 是否持久 | 适用 |
|---|---|---|---|
| 运行时注入 | dev_inject_plugin | ❌ 否(写 registry,重启时重放) | 开发期测试,试完 dev_uninject_plugin 退掉 |
| bundle 装配 | dev_install_package | ✅ 是(写 profile 的 dependencies + bundles) | 正式安装 |
两条路径同时生效会让同一个插件被装配两次(重复提示词段、重复工具注册)。 所以:先用注入验证,再退注入、走 bundle 装配。
bundle 路径的硬性前提:dsh-app-boot 会校验 dsh.profile.bundles 里的每个包
(dsh-app-boot/lib/index.js:851):
const declared = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")).dsh?.bundle?.patch
if (declared === undefined) throw new Error(`profile bundle "X" declares no dsh.bundle in its package.json`)
只要包名在 bundles 里而缺少这个声明,DSH 启动就整体失败 —— 比插件崩溃更严重。
本包因此声明 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
cordis.patch.yml 即正规装配入口(一条 insert 条目),与 dev_inject_plugin
指向同一份 lib/index.js,行为一致。
另有一条易漏的坑:dev_uninject_plugin 会往 profile patch 写一条
- id: / disabled: true 阻断自装配。若之后改用 bundle 装配,
必须先注释掉那条,否则重启后插件被自己屏蔽。
四层防线
| 层 | 内容 | 命令 |
|---|---|---|
| 构建 | 7 项校验(含 bundle 元数据 + WeakMap 键审计);不过就拒绝写 lib | node scripts/build.mjs |
| 测试 | 124 项,含字符串 sessionId 致命回归、③ 的两条触发路径、三道闸、用户意图豁免(5j)、落盘闸自愈路径、超限截断注入、成本回归 | node test-plugin.mjs |
| 闭环 | 7 项:检查点正文注入、同代逐字不变、跨代换新 | node scripts/verify-closed-loop.mjs |
| 运行时 | register() 守卫 + ctx.get() 软解析 + 降级分支 + 能力探针 | 内建 |
| 实机 | 压缩归属权判据(插件预约 vs DSH 原生兜底)+ 按事件类型/按行号取证 | node scripts/verify-coupling-live.mjs、scripts/list-events.mjs、scripts/tail-session.mjs、scripts/dump-event.mjs |
构建刻意不做编译(src/index.js → lib/index.js 是校验后复制):历史上
「重新构建」曾把手写 lib 覆盖回旧脚手架,直接导致插件挂起。
目录
src/index.js 本体(也是构建输入)
lib/index.js 构建产物(装配入口,必须与 src 逐字节一致)
cordis.patch.yml bundle 装配入口
package.json 含 dsh.bundle.patch 声明(bundle 路径硬性要求)
INSTALL.md **分发到别的 DSH 终端**的安装与验收说明(先读这个)
skill/context-checkpoint/ 随包分发的技能(SKILL.md + scripts/,导出时从活 skill 同步)
scripts/build.mjs 校验 + 复制,绝不编译
scripts/install.mjs 目标机安装器:插件 + bundles + 压缩后端 + skill(幂等 / --check / --uninstall)
scripts/export.mjs 导出构建器:校验 → 同步 skill → npm pack → 组装 dist → 干净环境冒烟 → 压缩包
scripts/list-events.mjs 按**事件类型**列出事件(解析后比较 type,绕开 shell 引号)
scripts/tail-session.mjs 会话日志尾部取证(尾部 N 行 + 字面子串过滤)
scripts/dump-event.mjs 按行号 dump 事件的**完整 JSON**(--grep 打命中处上下文 + 偏移)
scripts/verify-coupling-live.mjs 实机取证:压缩归属权 + 闭环特征
scripts/verify-closed-loop.mjs 闭环 7 项验证
test-plugin.mjs 124 项回归(含桩 ctx,忠实模拟 cordis 语义)
dist/context-checkpoint-service-/ 导出产物(目录 + zip,可直接拷给别的终端)
为什么
list-events.mjs是必需的:tail-session.mjs的过滤参数是对原始 JSONL 行做字面子串 匹配,想精确匹配"type":"system/message"就得把双引号传进命令行 —— 而经 PowerShell 传参会把 引号吃掉(实际送进去的是空串 → 过滤条件退化成"匹配所有行",输出看着像没过滤)。list-events.mjs改成解析后按ev.type精确比较,并顺手汇报每条事件里的generation: N。
⚠️ 两个会话日志取证脚本默认只看本项目目录(--E-dsh~0020workplace--)。
本机同时有多个项目在跑,日志目录是按 mtime 混排的 —— 早先按"最新会话文件"取,
结果行号对得上、内容却是别的项目,取证结论差点作废。要跨项目看时显式加 --all。
装配(本机走注入;分发到别的终端走 bundle + 安装器)
分发/移植 → 读 INSTALL.md,一条命令:
node scripts/export.mjs # 本机:产出 dist 服务包(目录 + zip)
node install.mjs --dsh-home "" --profile
# 目标机:装插件 + bundles + 压缩后端 + skill
导出包 = 插件 + skill + 安装器,因为 1-2-3-4 里第 ② 步(落盘 important_view)靠 skill 的纪律,
插件只提供工具与注入。安装器是幂等的,支持 --dry-run / --check / --uninstall,
并且导出的 tgz 里含 skill/,所以 npm install 也是完整服务。
开发态用运行时注入:
dev_inject_plugin { "dir": "" }
dev_uninject_plugin { "match": "context-checkpoint" }
⚠️ 两条路不要同时用(同一个插件会被装配两次:工具重复注册、提示词段出现两遍)。 本机现在是注入态;
--check会如实报告 "bundles 缺条目" —— 那是刻意的,不是故障。
dev_install_package(bundle 路径)在本机被证伪 —— 2026-09-23 实测:
DSH Desktop 启动时会重写 profile 的 package.json(19:53:02 那次把本包的
dependencies 与 bundles 条目一起抹掉),而 bundle 路径还额外要求包里有
dsh.bundle.patch 声明,缺了会让 DSH 整体启动失败。插件因此声明了
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } },只为不踩那颗雷,
本机的开发态装配一律走注入;分发到别的终端才走 bundle,并在装完后用
node install.mjs --check 复核条目还在不在(Desktop 会重写)。
⚠️ 改完代码必须重启 DSH:注入器复用 Node 的 ESM 模块缓存,uninject → inject
只换 registry/loader 入口,不会重新 import 已加载过的模块。本轮实测:
注入返回 host ✓,但 context_status 里仍是旧版本的字段(新探针字段一个都没有)。
dev_reload_package 在本机直接报 loader.internal 不可用,指望不上。
判别方法:重启后看 context_status 里有没有这四个字段 ——
checkpointFresh、checkpointAgeMs、userIntentWindowMs(应为 900000)、
recentUserIntent(null 或 {word, ageMs}),外加
capabilityProbe["compaction.compactIfNeeded"] === "present"。
只有新版本才有;旧模块只给出 checkpointFile / checkpointBytes。
⚠️ dev 探针会重放:注入记录写进 registry,重启时全部重放;测完核对 dev_injected_list。
已知局限
- 需要 profile patch 才拿得到压缩服务:
ctx.compaction在 web 平面默认被dsh-web-appdisable;本机已在.dsh/profiles/web/cordis.patch.yml加- id: compaction-basic+disabled: false装回(重启生效)。 回退:改回disabled: true后重启。 - 压缩失败或无事可压时不唤醒续读(不谎报"已压缩");预约请求会被清掉, 由下一次越线提醒重新预约。成功的压缩在回合内是透明的:本回合照常继续,无需唤醒。
- 预约有门槛(窗口 50%):占用还低时调用
context_compact只落盘不压缩 —— 这是刻意的,避免把还在用的历史白白压掉。但用户本人明确要求总结/落盘/压缩/开始项目时, 门槛自动让路(D-006),返回文案会写明是按哪位用户的话放行的;其余情况实机验证请用context_compact { force: true }。 - 预约还有落盘闸(检查点必须是 10 分钟内写过的):长回合里模型如果在回合开头写了文件、
到回合末才调用,会被要求重写一次再调用。这是刻意选的保守方向 ——
误伤的代价是多写一次文件,漏判的代价是不可逆地丢结论。
force: true可绕过, 用户意图豁免不绕过它(用户要的是"固化状态",状态没落盘就压缩正好违背他的意图)。 - 用户意图豁免(D-006)的判据是保守的:只认真正的用户消息 + 固定词表 + 15 分钟窗口。 代价是"用户用词很偏"时可能识别不到 —— 退回门槛,不会误触。
协议
MIT License —— 见 LICENSE。可自由用于商业项目,保留版权声明即可。