DeepSeek Harness Plugin Hub

Publish and manage complete Harness Profiles. Discover Plugins for your next setup.

Explore

PluginsPresetsDocsNews

Community

Publish a pluginContactReport an issue

Resources

Plugin Hub on GitHubDeepSeek HarnessSystem statusPrivacy notice
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

Independent and unofficial. Not affiliated with, authorized by, or endorsed by DeepSeek.

DeepSeek Harness Plugin Hub
ProfilesPluginsCategoriesNewsDocsSign inManage Profiles
ProfilesPluginsCategoriesNewsDocsSign in
← Plugins
G

dsh-graft-slice

Graft Slice

DSH open-source ecosystem plugin (topic: dsh-plugin): physical handoff for long sessions: slice a closed log at its last turn/end into a zero-inheritance, cold-resumable session. No fork, no lossy compaction, crash-safe transaction journal.

The plugin will be installed here. Keep web if you are unsure.

npx -y @deepseek-ai/dsh plugin --profile web add github:corvus-dsh/dsh-graft-slice#ecf6b4ac84c72b6ca2b9035dca6a60706b0da49e
READMECompatibilityVersions

Compatibility and provenance

Graft Slice is published as dsh-graft-slice and currently resolves to version 1.0.1. The Hub verifies its manifest and preserves the exact installation source for reproducible installs.

DSH compatibility
*
Runtime surfaces
web
Release source
github
Registry updated
11/4/2026

Versions

1.0.1stable
11/4/2026
0.21.0stable
11/4/2026
0.20.0stable
11/3/2026
Show 6 more versionsCollapse versions
0.19.0stable
11/3/2026
0.11.0stable
11/3/2026
0.10.0stable
11/3/2026
0.8.1stable
11/2/2026
0.8.0stable
11/2/2026
0.7.4stable
11/2/2026

Related plugins

Loading related plugins…

Latest
1.0.1
DSH
*
HMR
Process restart
Tree shaking
Safe tree shaking not declared
Unpacked size
Unavailable
Files
Unavailable
Surface
web
License
MIT
Source
github
GitHub
★ 1
Weekly downloads
0
Last push
11/4/2026
View source ↗
README badge

Click the badge to copy Markdown for your README.

Do you maintain this Plugin?Claim benefit · Priority security scan

Verify the GitHub repository declared in package.json to manage this listing. After you claim it, Hub will prioritize a security scan of the current version and publish the result when it passes.

Claim this Plugin →
Report an issue

Related plugins

More verified plugins in memory-context.

Experimental Session Search@deepseek-ai/dsh-experimental-session-searchGive Agents tools to search and read earlier sessions from the current workspace.Contextdsh-contextA DeepSeek Harness plugin for context insight and management, with context dashboard and context command, for understanding how the context is made of, and how it evolves.Memory Plugin@openviking/dsh-memory-pluginOpenViking memory and context bundle for DeepSeek HarnessAb Memorydsh-ab-memory--- description: "The dsh-ab-memory package: a cross-session, file-backed agent memory with five tools (remember / recall / forget / load_skill / distill) and an always-on light injection." kind: "package-reference" ---

README

dsh-graft-slice

兄弟项目:dsh-memory —— 能力记忆(SKILL.md / 负知识),与本插件只通过台账文件耦合。

DSH 日志切片与最小注入插件。 在源会话最后一个 turn/end 处切取高保真磁带,创建一个零继承、可冷恢复的交接会话;不 fork,不引入 SQLite,不单独注入 tool/result。

当前版本 0.22.0(预发布)(v7.4 能力线,仓库语义化版本从 0 起)。 发布说明:RELEASE_NOTES.md(v0.8.1 归档见 RELEASE_NOTES-v0.8.1.md)。 目标运行时:DeepSeek Harness 0.2.1-rc.5(当前桌面端)。 规格书 v2.1 写的是 rc.4;本实现已按 rc.5 的真实源码校正了两处会导致冷恢复失败的问题。

愿景:事实记忆只需可查,能力记忆才需要持久化

上下文窗口会腐蚀:窗口越长,注意力越散,结论越容易被自己的历史淹没。所以这个生态不打算把所有东西塞进一个窗口, 而是把「记忆」拆成两类,按不同的成本分别对待:

记忆需要什么承担者
事实记忆:做过什么、当时的原文、工具输出只需可查会话日志 + 按 seq 回读(graft_read_result / graft_search_history),不重放、不摘要
能力记忆:怎么做才对、踩过哪些坑、有效方法必须持久化记忆系统插件:记忆的存储与转化,长期记忆与能力飞轮的基础设施

持久化能力层由三件插件组成:

插件职责
嫁接切片插件(本仓库 dsh-graft-slice)工作能力连续性:在上下文爆掉之前换一个干净窗口,摆脱上下文腐蚀、重置注意力;切片 / 两阶段审核 / 崩溃可恢复
记忆系统插件(规划中)记忆的存储与转化:把做过的事沉淀成可复用的能力,作为长期记忆与能力飞轮的基础设施
副会话插件(规划中)隔离用户消息:为主会话提供专业、具体的方案,并维护记忆系统

三者共享的硬约束:不 fork、不改写源日志、只追加、每个指针都能被 graft_read_result 打开; 迁移不能带走现场——.txn/ 写前日志 + 幂等恢复,任何时刻 sourceUsable ∨ targetUsable。

本项目是 DSH 开源生态的一部分:GitHub topics 标 dsh-plugin,发布以预发布(prerelease)先行。

真机自我切片(本项目第一次)

项数值
源会话48 轮 / 14,978 事件 / 9.5 MB 压缩日志
交接注入14,252 字符(system 1,125 + 磁带 13,127),源的 0.15%
磁带结构1 轮封闭骨架 + 8 个回顾块(goal / task-status / workspace / carryover / readback / pending …)
断点open-turn-carryover 370 字符(Turn 47,进行中)+ recent-turn 5,889 字符(Turn 46 全文)
回读指针 1,860 处;可回读索引 1,982 条
结果新会话零澄清提问接上工作,并自行发现 pending 与断点的矛盾

安装与准入校验

本插件是 DSH bundle:加载器只认 package.json 里的 dsh.bundle.patch,普通依赖会被静默忽略。

# 用户安装(dsh CLI)
dsh plugin --profile <name> add dsh-graft-slice
dsh --profile <name> --dump-config        # 配置树里应出现 dsh-graft-slice 条目

仓库自带的契约校验器(不依赖 dsh CLI,可在 CI 里跑):

node scripts/verify-bundle.mjs   # 27 项:bundle 声明 / files 白名单 / ESM / manifest 一致 / patch 形状 / 来源审计 / 脱敏

三条高频致命错误,本仓库都用校验器兜住:

错误后果本仓库
只声明 main/exports,漏 dsh.bundle被当成普通依赖,静默不激活dsh.bundle.patch 已声明并校验
files 漏 cordis.patch.yml装上了但没有配置层,挂载失败白名单含 patch + index.js + manifest.json
未设 type: "module"CJS 引 ESM 核心包,启动即崩type: "module" 已校验

cordis.patch.yml 用包名引用(name: dsh-graft-slice),不写相对路径——相对路径只在本机成立。

v0.20.0:交接任务看板(Task Board)

v0.19 蒸馏得对,但渲染成了一段话。读散文的模型必须自己推导"哪些做完了 / 哪些在跑 / 哪些没开始", 而这一步推导正是恢复的会话开始重做已完成工作的入口。看板把四列写明:

## 交接任务看板(第一轮只要读这里)
**原始用户要求**:把两个插件升级到 v0.20 并跑通全量测试

**✅ 已完成**
- git commit abc1234 成功(seq=88)

**🔄 进行中**
- 正在改 retrieve.ts 的任务对齐过滤

**⏭️ 待开始**
- 提交并推送两个仓库
  • 「🔄 进行中」是 v0.19 缺的那一列:断点落在开放轮次时,那里的工作既不是"已完成"(还没有结果), 也不是"待开始"(已经开工)。并进「待开始」会让目标重做,并进「已完成」会让它以为已落地。它取自开放轮次 自己的未执行指令,与 open-turn-carryover 共用同一提取器,两者不会互相矛盾。
  • 空列要声明:缺失的列读作"未知",显式 - 无 读作"确实还没有"——两条不同的指令。
  • 发 seq 就点名读它的工具:graft_read_result / graft_search_history 那一行补在停泊点之后, 否则目标手里只有坐标、没有用法。
  • 正文不再整段搬运:散文的"我接下来需要 X"被移进「⏭️ 待开始」(模型没写 todo 时它就是唯一计划); 排除项与回读提示保留为注记;其余判重丢弃——看板复述自己只会更长,不会更清楚。
  • 尾部规则常驻:旧会话的历史执行日志和临时错误无需理会……请直接执行上面「⏭️ 待开始」里的待办项。 同一句话也写进点火 Prompt:一次性消息会过期,常驻块才是长期规则。

v0.19.0:切片极限瘦身 + 物理点火(最后两块拼图)

前两个版本让交接正确;这一版让它干净并且自己会跑。

1. 语义状态提取(不再搬运旧房子的报错)

open-turn-carryover 以前把开放轮次「基本原样」搬过去:助手叙述逐字、指针逐行,加上 renderLastStepScene 里那条完整报错正文(上限 2000 字符的栈回溯)。这是目标既不能执行、 也不能验证的最大一块文本,而且它把旧会话的问题带进新上下文——新会话开局先修一个早就修好的东西。

现在只提炼两件事 + 一个指针,构成一张指令卡:

## 交接指令卡(第一轮只要读这里)
【最终结果】git commit e8da8f6 成功(seq=14821)
【未决报错】pwsh 失败:Cannot find module 'left-pad'(原文 seq=14903)   ← 只有未解决的才有这一行
我接下来需要 …
【停泊点】Turn 47 已完成 · 边界 seq=14950 · 可回读指针 340 处 · sourceSessionId=…
【已按语义瘦身】丢弃:2 条已解决的报错、1 段报错正文、812 字符中间叙述;需要时按 seq 回读原文。
  • 最终结果:git commit <sha> 成功 / git push 成功(main) / 测试 N 通过 / 写入 <file> 完成, 每种都带 seq,所以它是可核对的声明而不是一句总结;
  • 已解决的冲突/报错强制丢弃:扫描自新到旧,遇到第一个成功就停;比它更早的失败都算已被覆盖 (计入 resolvedErrors,不进新上下文);
  • 唯一例外:一直没有被后续成功覆盖的那条报错,保留一行(正文 160 字符 + seq),因为它是 下一步的阻塞,属于「未执行的下一步」的一部分;
  • 丢弃必须声明:【已按语义瘦身】 列出丢了什么。静默删除是这套系统一贯要避免的失效模式;
  • 指针坐标保留:(本轮过程指针:seq=9,15,23;正文按 seq 回读)——散文丢了,可达性没丢, 「每个结论都有 seq 可开」这条契约不变;
  • 点火指令不算人类约束:extractConstraints 跳过带 [dsh-graft-slice 点火] 标记的消息。 它是以真实 user/message 投递的,按来源看像人话,但它是我们写的引导语;把它当硬约束传下去, 下一代就会把「继续执行任务」当成人亲口声明的规则。

2. 物理点火(新会话自己开跑)

在此之前,每个版本产出的目标都是正确但静止的:目标拿着 goal、磁带、指令卡和约束,然后等人点它。 而它替换掉的源会话已经被退休——交接成功的那一刻,工作停了。插件自己写的 selfSliceBootstrap(「无需等待用户确认」)只是一句写给目标的话,而没人启动的目标收不到任何话。

宿主有这个通道:sessionController.prompt(request, signal) ——「Admit one prompt after explicitly resuming its Session」。它正是 Web 输入框走的那条路,会从持久化里冷恢复会话,所以它也是插件让 冷目标跑起来的正规接口。graft_slice_and_inject 在 handle.flush() 之后、退休事务提交之后 调用它一次,往目标投递:

[dsh-graft-slice 点火] 你刚刚完成了一次物理切片:上一段会话的上下文已经交接给你…

幂等与诚实:

情形行为
通道存在、接受ignition.ok=true,记录 channel,清空队列
通道缺失attempted=false + 原因;迁移仍然成功;意图落盘排队重试
调用抛错/超时(20s)ok=false + 原因;不回溯迁移;同样排队重试
目标日志里已有点火消息alreadyIgnited=true,不再投递——重复投递会让目标把同一句话读成两轮用户意图
连续失败 3 次放弃并审计 ignite-failed(目标仍在,可人工打开)

队列文件 <dataDir>/.graft-pending-ignition.json,apply() 末尾 resumePendingIgnitions() 消费; 审计线记 ignite / ignite-failed / ignite-skipped。控制台新增「物理点火」卡片:开关、投递方式 (queue / steer)、以及未点火会话列表(含重试次数)。

3. 补正:ask 走真正的审批通道(v0.18 的权宜之计被替换)

v0.18 把 autonomous 下的 ask 写成「拒绝 + 让 agent 自己去要确认」。核对宿主契约后这不该由模型代劳:

通道能表达性质
tools.guard()只能返回拒绝字符串单调:没有 guard 能推翻另一个 guard 的拒绝
tools/pre-execute(瀑布)allow / deny / cancel / ask(reason, displayReason)可被下游覆盖,但缺少审批支持时 ask 自动降级为拒绝(天然 fail-closed)

所以两个决策各走各的通道:deny 走 guard(要它无法被撤销),ask 走瀑布(要真的弹给用户)。 顺序上 guard 在瀑布之后,因此 deny 仍然压过 ask——更严的判定存活。

v0.18.0:风险与策略分离、动态水位、硬约束零丢失、台账 epoch

审计的判词是「字符串防御」:scanAgentCommand 同时干了两件事——判定命令是什么(证据,正则擅长) 和决定拦不拦(策略,该由一张操作员能读能改的表决定)。这一个版本把四件事分开焊死。

1. 风险(evidence)与策略(policy)分离

  • classifyAgentCommand(command, workspace) → risk: safe | unknown | destructive,只给等级,不给裁决;
  • policyDecision(risk, mode) → 决策表,一处可读:
riskstrictbalanced(默认)autonomous
safeallowallowallow
unknowndenydenyallow
destructivedenydenyask

unknown 是"无法证明它被限制在工作区内"的变更类命令。destructive 在 autonomous 变成 ask 而不是 allow:模式可以降低仪式感,但没有任何模式会静默执行无界删除。tools.guard() 是同步的、只有"拒绝" 一个返回通道,所以 ask 表达为指名确认步骤的拒绝:调用不执行,并告诉 agent 先把确切命令与影响交给用户确认。

审计线区分 command-blocked 与 command-asked。

2. 动态水位(写死的 0.70 / 0.75 退休)

estimateGraftCost(window) = fixed(6000) + package(窗口的一半字符 / 4) + growthPerTurn × growthReserveTurns
safeCeiling = 1 - estimatedGraftCost / windowLimit
hardAt      = clamp(safeCeiling, 0.50, 0.75)      # 0.75 = 历史实测的"最晚可切点",是天花板
warnAt      = hardAt - 0.05                        # 永远留出模型自救的窗口
  • 大窗口(128k):估价装得下 → 仍然 0.75 / 0.70(不回归,写成断言);
  • 小窗口(32k):hardAt = 0.6125,比旧的 0.75 更早切;
  • 2.6k 这种装不下一次交接的窗口:feasible: false 明确报出来(面板/调试面板都显示),而不是切在 50% 装作没事;
  • growthTokensPerTurn 就是"工具长尾"的增长预测(默认 1200 tok/轮);
  • watermarkMode: 'fixed' 原样恢复两个常量——它就是给差分测试用的。

3. HARD CONSTRAINTS 零丢失

旧选择器是 candidates.slice(0, 8) + 8000 字符 break,两者都静默:第 9 条规则不进目标,没人知道。 现在按容量打包(constraintBudgetChars,默认 8000),逐条不截断;装不下的规则进入 dropped 报告, assertConstraintsPreserved() 直接抛 GRAFT_ABORTED: constraints truncated——缩水的规则集不上路。 buildInjectionPlan 是所有路径(prepare / commit / auto)的唯一收口,所以没有构建器能绕过。 能返回的计划都带 constraintIntegrity(budgetChars / totalChars / kept / candidateCount / dropped: 0)作为正向证据。

4. 台账 epoch(跨进程防撕裂)

.retired.json / .transferred.json 现在写成信封 {epoch, at, pid, payload}。读取端四种结局: 无信封(epoch 1,v0.17 的旧形状,继续读,并在下一次写入时就地升级)、epoch == 2 信任、 epoch > 2 拒读并等下一次重试、信封缺 payload 拒读。dsh-memory 侧同样实现,且: memory_migrate_evidence 在台账被拒时不再猜"最新会话",harvest 报告里带 ledger.deferred 说明原因。

v0.17.0:宿主噪音过滤(tape 不再携带别人的机器路径)

审计发现:tape 里带着源会话所在机器的路径(C:\Users\<user>\AppData\...、/home/...、/tmp/...), 以及整盘扫描命令的输出。红线告诉模型不要去那里;stripHostNoise() 不再把路指给它。

三条规则,全部在 buildInjectionPlan 的单一收口处执行,所以每个 chunk 都被覆盖:

  1. 只含宿主路径的段落 → 整段替换为 [宿主环境噪音,已过滤](声明式省略,绝不静默删除——静默删除正是 逐轮预算规则存在的理由);
  2. 同时含宿主路径与项目路径的段落 → 保留项目内容,只把宿主 token 换成 [宿主路径已过滤] (丢掉整段会丢掉真实工作);
  3. 全盘扫描命令的输出(Get-ChildItem C:\ -Recurse、rg /、find /…)若不包含项目内路径 → [命令输出已折叠];工作区内部的搜索原样保留。

renderWorkspaceState() 同样过滤:产物索引里出现宿主路径,等于告诉目标"这是你的工作区文件"。

验收测试:往日志的 assistant 结论与 tool/result 里塞 C:\Users\<user>\AppData\...\logs, 切片后目标会话里不再出现该路径,且省略处有显式标记(6 项测试,含工作区索引那一路)。

v0.16.0:Web 控制台(不再手改配置 + 不再重启)

在此之前,改一个开关意味着:找到 cordis.patch.yml → 改 YAML → 重启宿主 → 丢掉当前会话。结果是这些开关 在实战里等于冻结。现在有两个面板(本插件一个、dsh-memory 一个),从侧栏进入:

面板内容
转生控制台modelDrivenGraft / autoGraft 开关;modelDrivenGraftAtRatio / autoGraftAtRatio 滑条+输入框;护栏开关(workspaceRedline / commandGuard);实时水位表(每个活动会话的 token 用量、百分比、距 70% 与 75% 还差多少 token、第几代迁移、是否在 grace 保护期内)
记忆控制台由 dsh-memory 提供:retrieval / workspaceIsolation / semanticFilter 开关、按 scene 过滤的技能清单、一键清空当前工作区记忆

为什么这需要一个新的写入路径

面板不能把值写进插件内存里——那样重启就丢,等于换个地方手改。所以两个面板都走宿主的 configEditor.edit(entry, change):找到自己那一行(patchId: 'dsh-graft-slice'),在 current 之上 spread 一个键,宿主完成校验、落盘与 Loader 调和。值写进的就是 profile 配置本身,因此既无需重启、 也能活过重启。

同样的操作还注册成命令(/graft-console、/graft-console-set modelDrivenGraftAtRatio 0.70)。 UI 与命令共用一份实现,而且在客户端 bundle 尚未加载时,命令是立刻可用的退路。

已验证 / 未验证(如实标注)

  • ✅ 宿主侧逻辑:6 项新测试覆盖取值白名单(拒绝 0 与 ≥1 的水位,而不是静默 clamp)、 patch 合并保留未管理键、写入目标是自己那一行、configEditor 缺失时报错而不是假装保存。
  • ✅ 面板与命令同源:命令 handler 直接调用与面板相同的函数。
  • ⚠️ 页面内的按钮点击:客户端→宿主调用的客户端侧参数形状无法从 bundle 里确证(各宿主版本不同)。 因此桥接层按顺序尝试若干形状,并把实际生效的通道名显示在面板顶部;全都失败时它给出确切要输入的命令, 而不是一个永远转圈的按钮。
  • ⚠️ 需要一次宿主重启 + 一次页面刷新:dsh.client 是宿主动态扫描的字段,而客户端半边是新加的, 宿主要重新扫描才会把它放进 boot graph(之后改面板代码只需刷新页面,改配置则完全不用重启)。

已验证能力(v0.15.0 实战验收,2026-11-03)

上一版本致命问题的取证与修复全过程见 POSTMORTEM-2026-11-03.md (只读取证、代码证据、5 代污染链、564 条幽灵会话、以及三条“不是问题”的澄清)。

本节只记录真实跑过的结果;没跑到的部分如实标注。

项结果证据
全量测试graft 321/321、memory 138/138node --test test/*.test.mjs(两仓库)
文档/打包一致性check-docs 27 项一致、verify-bundle 27/27同上
幽灵条目清理projcache 574 → 10(幽灵 564 → 0)、retired 5 → 0、targets 8 → 0scripts/prune-ghosts.mjs(隔离到 _quarantine-ghost-<ts>/,含 MANIFEST.json + restore.ps1,未删除任何文件)
调试面板真机注册表可查到 graft_debug_status;CLI 输出与取证报告逐项吻合cordis_inspect_query Tool.listTools、scripts/debug-status.mjs
命令护栏(D1/D6)在我自己的会话上实战触发:一条 Get-ChildItem + $env:USERPROFILE 的搜索被拒,理由与替代方案一并返回会话内被拒记录;test/v81-guardrails.test.mjs
双时态失效一条 invalid_at 已过期的旧规则在真实记忆库里从未被召回(blockedInvalid 计数确认)见 dsh-memory README 的验收段
取代裁决真实记忆库上执行 supersede:旧技能写入 invalid_at + superseded_by + 正文「已被取代」,新技能写入 supersedes,双边互链,.memory-audit.jsonl 记账同上
人工否决权用 tool 来源技能取代 human 来源规则 → refused:human-loser-protected,旧记录零改动,审计留痕同上
来源标记提示中真实出现 [来源:工具推断,请核实]同上
水位面板对真实闭合会话算出 1.14%(11,948 / 1,048,576,source=usage),并给出距 0.70 / 0.75 的 token 差debug-status.mjs --session <id>
主动转生 @70%⚠️ 未在真机上触发:本会话上下文远低于 70%,无法在不伪造水位的前提下让它自然越线。触发逻辑由 test/v76-*、test/v81-guardrails.test.mjs 覆盖,graft_prepare → graft_commit 两阶段路径可用 graft_prepare 现场验证见下

关于最后一行:graft_debug_status 显示的当前水位是 below-warn-line,要真正观察“模型自己收尾并切走”, 需要在长任务里自然跑到 70%;不伪造水位是本项目的一贯纪律(见 test/v7*.test.mjs 对 readTokenWatermark 来源标注的断言)。

v0.15.0:版本对齐(v0.15.0)+ 与记忆侧的联合防线

dsh-graft-slice 与 dsh-memory 统一到 v0.15.0(package.json / manifest.json / README 三处对齐), 一个版本号覆盖两件同时发布的事:

  • graft 侧:七道防线(D1 封杀全局搜索 / D2 防死循环切片 / D5 进程级锁回收 / D6 危险命令拦截, 以及 memory 侧承载的 D3 工作区硬隔离 / D4 注入前防污染 / D7 一次性约束过滤)+ graft_debug_status 调试面板。 详见下面 v0.14.0 与 v0.13.0 两节。
  • memory 侧:双时态记忆(valid_at / invalid_at / superseded_by)、取代裁决(LLM 判定 → 互链 → 审计 .memory-audit.jsonl,且 human 来源不可被推断失效)、来源分级(human / tool / inferred, 非 human 的提示带 [来源:工具推断,请核实])。

两侧合起来才构成完整闭环:graft 决定“模型在哪、能做什么”,memory 决定“哪些结论能进这个会话、什么时候不再算数”。

v0.14.0:七道防线 + 调试面板

v0.13 的两道护栏是提醒;v0.14 把它们落到工具层,并补上“连环切片”“死锁”“注入污染”三类 只有在真机上才暴露的问题。一句话原则:提示是请求,守卫是拒绝。

#防线落点行为
D1封杀全局搜索tools.guard + system-prompt section + pre-step 一次性注入rg /、Get-ChildItem C:\ -Recurse、任何指向盘符根 / AppData / 用户目录的搜索在 dispatch 前被拒绝;工作区内部的搜索照常
D2防死循环切片isProtectedGraftTarget 无条件生效 + nudge 抑制 + graft_generation 注入目标前 2 轮(graftTargetGraceTurns)任何自动路径都切不动它,包括 75% 兜底;nudge 也不再邀请它自杀;目标知道自己第几代
D3记忆 Workspace 硬隔离dsh-memory 写入记 workspace:,读取端池级门禁跨工作区、无 provenance 的记录一律不注入(scope: global 例外)
D4记忆防污染dsh-memory/src/threat.ts 在注入前再扫一遍ignore previous instructions / 忽略以上指令 / forget everything… 直接丢弃;连未被渲染的正文也扫
D5进程级锁清理apply() 启动时 scanAndReclaimLocks()按 owner 里的 PID 判活:死进程的 .lock 目录直接回收并记审计;活持有者绝不碰
D6危险命令拦截与 D1 同一个 scannerrm -rf /、Remove-Item -Recurse C:\、逃出工作区的递归删除被拒;Remove-Item -Recurse .\build 照常
D7一次性约束过滤extractConstraints + LLM 约束提示「立刻删除 <工作区>\repair-log.cjs」这类只安全一次的步骤不进 HARD CONSTRAINTS

D1/D6 为什么是「拒绝」而不是「提醒」

实测的失效形状:一个会话被要求找某个文件,在自己的目录树里找不到,于是一路外扩——先 profile、 再 AppData、再到盘符根——一轮一轮地搜,并对一个“只是列过目录”的文件系统给出自信的总结。 v0.13 的提示写在 system-prompt 里,而 system-prompt 是建议:模型可以读、可以同意、然后照样 rg 整个盘。ctx.tools.guard() 是单调拒绝:命中即在 dispatch 之前返回理由,任何其他 guard 都无法把它改回允许。这正是“提醒”和“拒绝”的差别。

判据刻意窄:

  • 拒绝「搜索类动词 + 逃逸目标(盘符根 / / / ~ / %APPDATA% / C:\Users…)」; 读取某个具体 C:\... 文件不拦——那是正常工作,拦了这护栏活不过一天。
  • 拒绝「破坏性动词 + 逃逸目标或工作区之外的递归目标」; Remove-Item -Recurse .\build、rm -rf node_modules 不拦(相对路径按定义就在工作区内)。

D2 的洞:为什么“已经有的守卫”没挡住连环切片

graftTargetGraceTurns(默认 2)早就存在,但它被嵌在 ignoreTransferGuard !== true 里, 而两条自动路径(75% 兜底、确认模式)都传了 ignoreTransferGuard: true。也就是说: 恰恰在无人值守的那条路上,连环守卫被跳过了。 绕开“已退休/已转移”的拒绝不等于绕开“这是刚出生的 目标”的拒绝,两者现在分开了(forceSlice 才能显式绕开,且自动路径都不传)。

调试面板 graft_debug_status

面板内容
watermark当前 token / 窗口 / 百分比 / 数据来源(tokenMeter | usage | incremental | estimate),距 70% 提示线还差多少 token,距 75% 兜底线还差多少,以及所处区间
locks现存 .lock(owner pid、存活、年龄)、.txn 未完成事务、.retired.json 退休数、.transferred.json、graft target 及其代数
memory技能 / 负知识 / 事实 / 经验 / 待审队列计数;带 workspace 时给出「同工作区 ok / 跨工作区 foreign / 无 provenance unscoped」以及脏数据样例

同一个面板也有 CLI(无宿主时用):

node scripts/debug-status.mjs --data <state-dir> --session <sessionId> --workspace <path>

CLI 的锁扫描是 dryRun,只读:诊断永远不改状态。水位来自日志里真实的 provider usage; 没有 usage 就如实返回 unavailable,不编一个和宿主 meter 不一致的数字。

v0.13.0:两道安全护栏(工作目录红线 + 一次性动作过滤)

自动迁移开久了,事故不出在“切片本身”,而出在切过去的那个模型不知道边界在哪,以及 被带过去的那条规则只对一次有效。两道护栏各堵一个方向,都默认开启。

① 工作目录红线(workspaceRedline,默认 true)

现场:一个迁移后的会话被问到某个文件,在自己的目录树里找不到,于是按通用 agent 的直觉 一路外扩搜索——先 profile 目录、再 AppData、再到盘符根——一轮一轮地搜,并对一个“只是列过目录” 的文件系统给出自信的总结。注入的 tape 里没有任何一句说明“这里”的边界在哪,所以“再找找”是无上界的。

修法:tape 本来就只记了一个工作目录,那就把它说出来,连同“找不到时该做什么”:

【红线警告】你的工作目录严格限定为 <cwd>。严禁执行任何针对 C 盘、AppData 或全局根目录的搜索 (如 rg /)。如果遇到找不到文件的情况,请只在当前目录下用 ls 或 dir 查找。 同一路径被拒绝或找不到之后,不要换写法重试、不要向外层目录扩散搜索、不要凭目录列表猜测文件内容; 请把结论和缺口报告给用户。

路径来自会话日志头(plan.cwd),不是模型输出,所以模型无法把它谈走。

注入两次是刻意的:system/message(节点 0)在目标第一次真实 step 时会被宿主自己的 SystemPromptProjection 覆写,只放那里等于没放;所以同时写进 goal user/message——那是宿主 永不重写的通道,和 HARD CONSTRAINTS 走同一条路。

② 一次性动作过滤(extractConstraints + LLM 约束提示)

现场:某源会话的用户消息开头是

  1. 删除危险脚本:立刻删除 D:\...\repair-log.cjs,绝不能让这个无备份、强制覆盖的脚本再跑一次。

抽取器把它留下了——它是祈使句、有动作、够具体、够长,打分很高——于是迁移后的目标在同一条 HARD CONSTRAINTS 里一直背着“删除 repair-log.cjs”,而那个文件早就没了。点名一个具体产物 + 一个 变更动作的句子是任务步骤:它只安全一次,之后就是把枪口对准该路径下一个东西。

判据不是“是否破坏性”(长期规则当然可以禁止破坏性动作),而是是否可泛化: 句子里同时出现「具体文件/路径」与「变更动词」、且没有约束住动作本体的长期标记 (禁止/绝不/每次/任何/never/always/only…),就丢弃。

豁免窗口刻意很短(12 字符)且动作表刻意很窄,因为上面那句里也有“绝不”—— “绝不能让这个…脚本再跑一次”说的是另一个动作。标记必须压在动作上,不能只是离得近。 正则路径与 LLM 提示两条抽取路径都写了同一条排除项,否则模型那条会悄悄把正则删掉的东西加回来。

配套:记忆侧的工作区物理隔离(dsh-memory v0.8)

切片护栏管“模型去哪找文件”,记忆护栏管“哪条规则能进这个会话”。dsh-memory 现在写入时记录 产出会话的 cwd,检索时只放行 workspace 一致或显式 scope: global 的记忆,跨工作区与无 provenance 的旧记录一律拒绝(fail closed)。两边合起来才是完整的“转生后不越界”。

测试:test/v80-guard.test.mjs(6 项)断言红线出现在真实注入产物的两个通道里、 workspaceRedline: false 确实能关掉护栏(不是装饰性开关)、一次性删除指令被丢弃、 而“禁止在没有备份的情况下删除任何脚本”“每次提交前必须更新 RELEASE_NOTES.md”这类可泛化规则被保留。

v0.11.0:计划前置、产物复用、退休原子、锁租约

四项加固,都来自“真正多项目并发”暴露的问题:

  1. 计划前置:pre-step 检测到 未完成 Todo > 3 且没有任何 阶段 N: 前缀时,注入 「请先分析当前任务,制定阶段节点(Phase),并明确每个节点的完成标准,使用 todo_write 保存。」 长任务先有图再走,交接单才有节点可带。
  2. 产物复用:pending-state 里,若本轮有 write/edit,列出 已完成节点产物:<path>;后续请直接 read 复用,避免重复生成。 目标会话不再重造同一个模块。
  3. 退休原子:.retired.json 与 .transferred.json 现在是一次两文件事务:任一写失败 → 两个文件回滚 + 内存集合回滚 + 抛 RETIREMENT_WRITE_FAILED;.txn 只在全部写成功后清理。 “写一半、告警、继续”会让同一源再切一次。
  4. 锁的持有人与租约:锁目录内新增 owner 文件(随机 token + pid + 时间戳), 持锁期间每 lease/2 续写(默认 30s 租约);释放时校验 token,非持有人拒绝删除; 等待方按租约时间判活,不再误抢活锁。

E2E:node scripts/e2e-multiproject.mjs 起 3 个独立 Project(各自 dataDir),并发完成 70% 自切片 + harvest + 租约续期 + 场景路由;断言“每个项目只退休自己的源、技能证据只引用自己的 session id、UI 查询不激活数据库技能”。12 项检查全过。

v0.10.0:并发安全 —— 状态文件加锁,两个会话同时转生也不互相覆盖

两个会话同时到达水位时,旧写法是"把自己内存里的 map 序列化后 rename 覆盖"。rename 保证读者 看不到半截文件,但保证不了隔离:后写者用自己那份不完整的视图,把先写者的退休记录整条抹掉。 真机后果:被抹掉的会话重启后"复活"再被嫁接一次(两个分叉 target),先写者则看到"已转移"、 报出一个并不存在的漂移。

修法三层:

层做法
互斥每个状态文件一把 mkdir 锁(<file>.lock)。mkdirSync 的 EEXIST 是跨平台原子的;锁龄超过 30s 视为崩溃遗留,直接夺取
合并写不是覆盖,而是锁内 read-modify-write:磁盘 ∪ 内存;删除必须显式传 remove,否则回滚/恢复删不掉
单写者每个源会话一把 source:<id> 锁;进入切片先重读磁盘台账,第二个写者得到 SOURCE_ALREADY_MIGRATED,而不是产出第二个 target

加锁范围:.retired.json、.transferred.json、.graft-prepared.json(并发 prepare 不再互相吞掉)、 .graft-targets.json、.auto-graft-suppressed.json、约束分节。

审计:.graft-audit.jsonl 记录 model-nudge / force-graft / lock-wait / lock-stale。 pre-step 提示不写日志,这一行是"提示真的发过、在多少水位发的"的唯一持久证据。

演练:node scripts/duel-model-driven.mjs 用四个真实进程共享一个 dataDir:A/B 在 73% 各自收到 nudge 并自己切走,C/D 在 76% 被强制兜底;脚本断言 4 个源全部退休、4 个 target 都带 自启提示、审计里有 2 条 nudge + 2 条 force-graft,且没有锁超时。

v0.9.0:Model-Driven 交接 —— 模型自己挑时点,系统只做兜底

75% 由系统直接斩杀,在长实现步骤里过于粗暴:模型正在写的句子、刚发出的工具调用都随之中断。 现在默认留 5% 安全余量:

水位行为
< 70%不变
≥ 70% 且 < 75%pre-step 注入一句提示(每轮最多一次):请模型先 graft_prepare 看交接单,确认后自己调用 graft_slice_and_inject
≥ 75%兜底防线不变:系统按 autoGraft 强制迁移
modelDrivenGraft: true        # 默认 true
modelDrivenGraftAtRatio: 0.70 # 预警水位;须低于 autoGraftAtRatio
autoGraft: true
autoGraftAtRatio: 0.75        # 兜底强切

提示走 pre-step 的 messages 通道(不写日志、不占 step 号),因此不会破坏 v4 的 step 时序; 同一 turn 最多注入一次。若 modelDrivenGraftAtRatio 配到 ≥ autoGraftAtRatio,预警窗口为空、 永不触发——不做静默钳制,配置错了就是没有预警。

Target 自启:切片产物的 pending state 底部固定追加一行 [系统提示] 你刚刚完成了一次自我切片。请直接接着“当前任务”继续执行,无需等待用户确认。 (pending-state 受预算保护,不会被截断)。关闭 modelDrivenGraft 时该行一并消失。

v0.8.0:Phase Roadmap(节点计划)

长任务需要一个比 todo 更粗、比 goal 更具体的层级:整体阶段 → 当前节点 → 卡点。

动作实现
提取三层降级:阶段 N:/Phase N/第 N 步 前缀(确定性)→ assistant 句首编号列表(启发式)→ ≥4 条普通 todo 退化为「节点」
不编造三层都没有 → 不产出该块
优先级[Phase Roadmap] 排在 [Task Status] 之前(状态回答“还剩什么”,路线图回答“我在哪、卡在哪”)
卡点取最近失败;若之后有成功调用则丢弃(不把已解决的错误当现状)
唤醒pre-step messages 注入一行提示:每轮最多一次 + 3 轮冷却,且已有阶段前缀时不打扰
清单时效若 todo 写完之后又有 ≥3 次成功的工具调用,pending 追加一行「清单写于 seq=N,之后又有 M 次成功的工具调用——某项可能已完成,先核对再重跑」

约定:写 todo 时用 阶段 1: … 前缀,切片会原样带走并确定性分组。

回归测试:test/v75-phase-roadmap.test.mjs(10 条)。

v7.4.0(P0/P1 修复):断点优先、进行中轮次不丢、两阶段审核

事故:一个 41 轮的真实会话在 75% 水位命中时正处于实施中途。auto-graft 在 turn-stopping 触发,比该轮的 turn/end 早了一个事件;autoGraftIfNeeded 把快照截断在「最后一个它看得见的 turn/end」——也就是上一轮的末尾。结果整轮(用户的决定 + 83 步实施)从磁带里凭空消失,target 把再上一轮读成「这个会话的任务」,并重做了磁盘上早已存在的工作。

病灶:extractOpenTurnCarryover(v7.0 B1,专门为这种场景写的携带块)因此永远读到空尾巴,等于死代码。三处都有同样的预截断:水位 auto-graft、冷读回退、溢出恢复。

修法:

locateBoundary       骨架仍切在最后一个「闭合」轮  → 红线不变(target 必须有自己的闭合骨架)
sourceSnapshot       不再预截断,开放轮原样进入 carryover
carryover            注入在磁带最前面(断点优先于状态)

配套约束:hasClosedTurn() 区分「根本没有可切边界」与「边界存在、后面还有活」;前者返回 undefined 让水位重试,而不是静默切一个更老的点。

回归测试(test/v74-breakpoint-priority.test.mjs):同一份测试跑在冻结的 7.3.1 构建上应有 3 条必红——the in-flight round must survive the graft; losing it is the bug this test exists for。这正是本次事故的最小复现。

配置默认值(v7.4 统一,四处逐字一致)

项源码默认tiers.yml线上 cordis.patch.yml说明
recentTurns5A=0 / B=1 / C=5B 档 = 1仅当 adaptiveDepth=false 时生效
adaptiveDepthtrueA=false / B=false / C=truefalse(B 档)true 时按会话形状自适应(debug 形状降到 0 轮)
autoGraftAtRatio0.750.750.75旧注释写 0.60、值却是 0.75,v7.4 把注释改对

以前四处冲突:源码 5 / 线上 patch 1 / tiers.yml C 档 0+false(与它自己上限 5 处的注释矛盾)/ README 写 5。现在 C 档 = 源码默认,不改配置跑出来的就是 C 档。

同一版本其余落地

项变化
两阶段审核(P1-2)新增 graft_prepare(只读,产出目标/进度/约束/遗留 + 切片哈希)与 graft_commit(校验 preparedId 时效、哈希、源 seq 未漂移后才写盘)。单步 graft_slice_and_inject 仍可用,但不再是推荐路径
回顾句(P4-1,方案 B)同句式的回顾按工具聚合为紧凑行:- pwsh(seq=364,372,380) · read(seq=398);! 标记报错,行序即时序。同一日志 A/B 实测 19,987 → 14,486 字符(−27.5%),指针数 1,812 不变
Pending 模板(P4-2)条件句改成「我接下来需要继续推进,前提是 …」;计划句只在句首匹配(下一步/接下来/待办),不再把引用里的 runs later 当成计划
归档 fork(P4-3)给 fork 会话在激活时挂到所属工作区并 emit api-session/added;此前 baseline-fork-demo 磁盘上有 1,200 事件、侧边栏零行
约束来源(硬约束 7)当 goal 里含继承的 HARD CONSTRAINTS 时,pending state 第一行写「核心约束源自第 N 代会话,seq=Y,必要时请优先回读」
自适应档位(决策 2)autoAdaptiveTier(默认 false):开启后,若最近有未完成 todo 或最近一次调用失败,重放轮数降到 1 轮(B 档);干净收尾则用 0 轮(A 档)。优先级高于 adaptiveDepth,结果写在 plan.adaptiveTier
Task Status 时效注入的清单带上 todo_write 的 seq 与「之后又过了多少事件/轮」,避免把做完的清单当成当前计划

解除退休后的宽限(v7.4 追加)

graft_resume_session 清掉退休标记后,日志并没有变小,水位仍然 ≥ 0.75——用户刚说一句话,就会被再次嫁接进一个新 target 并退休一次。本项目实测两次(第二次发生在解除后第一条消息)。现在 resume 会给该会话 30 分钟自动嫁接宽限,并落盘到 .auto-graft-suppressed.json,重启后依然有效;resetRetiredSessions() 也会清掉它,避免污染测试。

v7.4.0 追加(真实长会话嫁接后收紧)

handoff-demo 是本项目的第一次真机自我嫁接(源会话 48 轮 / 14,978 事件,切片 1 轮封闭骨架 + 7 个回顾块,合计约 1.3 万字符)。之后按观察到的问题收紧了三处:

缺陷现象修法
pending 与断点打架(硬约束 4)pending 写「立刻执行:用 graft_prepare 体检」,而断点块已经显示 graft_prepare(seq=14965) 且下一步是提交——状态压过了断点开放轮尾部最后一次工具调用与清单第一项同名时,pending 追加一行「断点前我刚发出过 X(seq=N),这一项可能已经做完了——先核对结果,不要直接重跑」
锚点漏掉人类粘贴的抽取副本(硬约束 7)约束块是插件抽取后塞进 goal 的,源日志里并不存在同名文本,于是锚点永远不触发改从源会话里最早一条人类 user/message 的 HARD CONSTRAINTS 取 seq;插件自述行(plugin:graft-replay、[系统提示] 本次任务的硬约束…)永远不算来源(硬约束 8 的 source.kind === 'user' 严校)
Subagent Reports 被工具输出污染5 条「报告」里 4 条是 read/pwsh/job_output 的原文(文件转储、测试日志),占掉约 1.7 KB关键词必须落在结果开头 60 字符内,且正文不得是文件/日志转储(<path>、行号、✔ 批量);清单只保留最近 5 条

| 断点块是拼的 | carryover 读起来像一条完整消息,实际是各步最终段落逐字拼接(真机 6/6 可回源),目标据此误判"这是我最后说的话" | 块头加一行:(断点块:下面 N 段正文按时间拼接自断点前各步的最终段落,不是一条完整消息) | | 未提交的冷目标无法打标 | 宿主不提供 delete / open-for-append,[损坏] 标题只能写给活动会话;flush 之后崩溃会留一个内容完整、看起来可续接的目标 | 新增 .graft-leftovers.json 台账:每次中断都记下 targetId/源/阶段/原因,并在下一次 graft_prepare 的摘要里明说「不要从它们继续」 | | 同一源被切两次 | 手动路径不看 retired/transferred 台账,session-source-demo 实际产出过 3 个 target | 默认拒绝切片已迁移源(SOURCE_ALREADY_MIGRATED),显式 allowReslice: true 才放行;graft_resume_session 仍是否决解除路径 |

这几条的回归测试在 test/v74b-handoff-coherence.test.mjs 与 test/v74-two-phase.test.mjs。

v7.3.1(关键修复):所有 target 都是宿主无法打开的

7.3.0 及之前创建的每一个交接会话,宿主都无法加载。 真机实测:

stored session "auto-graft-…" is corrupt:
SessionFormatError: session/title messageSeqs must cite distinct earlier
human user/message events

现场有 284 个这样的会话,全部无法打开。

规则(dsh-session-format-v3-to-v4 的 titleSources)

// 空 messageSeqs 与 source.kind === 'user' 必须同真同假
if (event.type === "session/title"
    && references.length === 0 !== (data.source.kind === "user"))
  throw new SessionFormatError("session/title messageSeqs must be empty exactly for a user title")

// 非空时,每个被引用的 seq 必须是更早的、source.kind === 'user' 的真人 user/message
for (const value of references) {
  const source = events[seq]
  if (source?.type !== "user/message" || source.data.source.kind !== "user")
    throw new SessionFormatError("… must cite distinct earlier human user/message events")
}

为什么必然违规

target 里没有任何真人消息——它唯一的 user/message 是插件写的 goal(plugin:graft-replay)。所以:

  • title 不能引用任何 seq(引用 goal 就是引用非真人消息)
  • 空引用必须配 source.kind: 'user'

而旧代码做了两件错事:初始 title 写 kind: 'fallback',append 路径又把 messageSeqs 覆盖成 [goalSeq]。唯一合法的形态是 { messageSeqs: [], source: { kind: 'user' } }。

为什么当时的测试套件没抓到

冷日志校验器只检查了「title 非空」「无 surface 元数据」,从未实现宿主那条 source 规则。校验器比宿主宽松,就等于没有校验。现在该规则已写进 assertColdLog,并有 3 条专门测试钉住。

存量修复

284 个损坏会话已就地修复(改动仅限 title 记录),原件备份于 <data-dir>\_emergency_backups\graft-target-titles-2026-11-02\。 修复后由宿主自己验证可读(graft_search_history 成功检索),并非仅凭本插件自检。

v7.3:检索补齐,默认切到零重放

前几版建了一套「按 seq 回读」的设计,但没有任何工具回答「哪个 seq 提到了 AssertionError」。注入的索引上限 24 条最近指针加最早的约束块,更早的内容原则上可达、实际上不可达——目标要么猜 seq,要么重跑命令。

graft_search_history(sourceSessionId, query, limit=10, regex=false) 补上这一环:

"error"  → found=true total=10
  seq=475  [tool/result]  Error: old_string was not found in "<other-project>\dsh-side…
  seq=610  [tool/result]  …err: 'var(--dsw-alias-state-error-primary)', Line 80: // no…
  seq=644  [assistant/message]  …说明真实 `$DSH_HOME` 曾被测试数据污染过…

搜索面是 assistant/message 与 tool/result(有载荷的两类事件)。默认字面匹配、大小写不敏感;regex: true 时按正则,编译失败返回 error 而不是抛异常。命中带 ±50 字预览与事件总长。

默认档位因此改为 A(recentTurns: 0 + adaptiveDepth: false):有了搜索,重放旧对话不再增加可达性。实测 A 档 2,268 字符、0 重放,与 C 档拥有相同的 342 个回读句柄。

其他 v7.3 修复

项之前现在
预算公式tokenBudget / 0.5(等于 ×2)。中文下 1 字符≈1 token,200k 窗口算出 200k 字符预算 = 吃满窗口1 字符 = 1 token,200k 窗口 → 100k 字符预算
溢出恢复可达性assistant/attempt 落在最后一个 turn/end 之后时永久 unsafe,P6 永不执行只有 stream 非空或存在 assistant/chunk 才算流式
撤回迁移只能手改 .retired.jsongraft_resume_session(sessionId):清 retired/transferred/连锁标记并落盘,不删除 target

v7.2 实测基线(真实会话 14 轮 / 3,882 事件)

三档注入的实际体积,用来回答「砍掉重放之后还剩什么」:

档位重放轮数总字符可回读指针
A 零重放02,076342
B 单轮17,597342
C 默认435,874342

两条结论,都有测试守着:

  1. 回读句柄与重放轮数无关——A 和 C 都是 342 条。重放轮次没有增加检索能力,只增加了体积。
  2. 因此 debug 模式在 7.2 起强制零重放(v6.3 的逻辑相反,会给 6-8 轮)。调试会话的近期内容是最不可压缩的堆栈墙,而最后一条报错原文已内联进 Pending-State(≤2000 字符,超出标 [已截断]),目标不需要再花一次工具调用去展开指针。

compaction-basic 的 auto: false 及其代价

profile 里对 compaction-basic 设了 auto: false,这会同时关掉 DSH 自带的溢出恢复:

if (this.config.auto) this._registerAutomaticCompaction()   // 三个监听器一起注册

关掉 auto 等于同时关掉 pre-step 压力检查、agent/request-error 溢出恢复、agent/status 清理。溢出恢复是可达的,但只由本插件的 P6 提供——监听同一个 agent/request-error,检测 CONTEXT_WINDOW_EXCEEDED 后迁移并返回 { kind: 'retry' }。没有第二层保险。

保留 DSH 兜底的做法是把 auto 改回 true,代价是两套压缩机制同时监听 agent/pre-step,顺序不可控。

文件

文件说明
src/index.ts完整 TypeScript 源码(类型、阶段注释、红线自检)
index.js由 src/index.ts 生成、DSH 直接加载的运行时
manifest.json插件名称、版本、声明权限、工具/钩子清单
cordis.patch.ymlLoader 插入行与默认配置
test/*.test.mjs538 个测试:切片、注入、红线、真实日志、profile 隔离、代码块指针、异步约束、草稿捕获、dataDir 隔离、真实 v4 校验器、产物索引、工具可用性降级、v7.0 重放/连锁守卫/约束漂移/结构伪造/代际污染/溢出恢复/预算优先级/压缩免疫/零重放/跨代追溯、v7.6 Model-Driven 水位、v0.13 工作目录红线与一次性动作过滤、v0.14 命令护栏/防连环切片/死锁回收/调试面板、v0.17 宿主噪音过滤、v0.18 风险策略分离/动态水位/硬约束零丢失/台账 epoch、v0.19 语义瘦身/物理点火、v0.20 任务看板
tiers.yml真机验收三档配置(A 零重放 / B 单轮 / C 默认对照组)
test/fixtures/真实日志派生的 v4 fixture(real-session.v4.jsonl.zstd + MANIFEST.json 记录来源、计数与 sha256)
scripts/build.mjs用 Node 内建 stripTypeScriptTypes 构建
scripts/demo.mjs端到端演示:真实日志 + 假宿主,不写真实存储
scripts/scan-real-logs.mjs对本机全部持久化日志做只读切片扫描
scripts/read-session-log.mjs读取多 zstd frame 的 v4 日志(开发工具)
scripts/debug-status.mjs无宿主时的 graft_debug_status:水位 / 锁与事务 / 记忆库,只读

对 v2.1 的两处必要校正

1. 骨架必须是 turn: 1 / step: 1,不是 0

DSH 的 v4 关系校验器从 1 开始计数,turn: 0 会抛:

turn/start does not open the expected turn

已用真实 @deepseek-ai/dsh-session-format-v3-to-v4 验证:synthetic-1-1 PASS,synthetic-0-0 按预期 FAIL。

2. 持久化要走 sessions.prepare + sessionPersistence.create + handle.append/flush,不是 sessions.create + sessions.flush

create() 会把会话发布进 live store,后续 agents.resume() 直接失败:

session "<id>" already exists

而 sessions.flush() 对 detached 会话会拒绝。正确路径与 AgentLoop.create() 内部一致:prepare → persistence.create → append → flush → close。inheritedEventCount 恒为 0,从不调用 fork()。

阶段映射

阶段行为
Phase 1sessionQuery.readSession,定位最后一个 turn/end,open turn 直接拒绝,提取 producer-owned assistant source(兜底 plugin:graft)
Phase 2只保留 assistant 文本;tool call/result 转成内联指针文本;最近 N turn 逐字保留,更早的 turn 压缩成摘要;抽取 goal 与第一人称 pending state
Phase 3开 turn/1 step/1 骨架,注入受保护 system/message 作为第一个 surface,注入 goal seed user/message(surfaceOp:'append'),注入摘要/磁带/pending 为 assistant/message(stream: []、无 sourceEventSeqs),闭合骨架
Phase 4经 sessionPersistence 落盘,冷读校验,源会话进入退休集合,agent/pre-step 返回 { kind: 'reject' }

为什么必须注入 system/message 头

冷恢复时,被恢复 agent 自己的 system prompt 若排在 user/assistant 之后,会被拒绝:

system/message requires a protected first surface head

所以第一个 surface 必须是 system/message。

v3.0 加固

  • 边界推断、open-turn 拒绝、source template 提取全部前置到任何写操作之前。
  • 冷读校验逐事件比对类型、seq、surfaceOp,确保产物与写入一致。

v3.1 生产级 UX 收尾

  • 注入 session/title,侧边栏显示 [接续] <goal 前 20 字>。
  • 自动挂载到源会话所属 workspace,避免"未分组"。

v3.2 约束锚定

  • 从 user/message 抽取硬约束,注入 HARD CONSTRAINTS 区块。
  • 约束独立于 assistant 摘要:原始约束通常在用户消息里,而不是助手选择总结的内容。

v3.3 / v4.0 自动迁移与状态延续

  • Task Status:解析 todo_write、- [ ] / - [x]。
  • Subagent Reports:抽取子 agent 回报。
  • v4.0 autoGraft:token 水位触发,Single Writer 通过 pre-step 闸门 + transfer 映射实现。

v4.2 强制枚举

  • HARD CONSTRAINTS 改为严格有序列表 1. [ ] ...。
  • 增加死命令:If asked about constraints or progress, you MUST enumerate every single item without skipping or summarizing.

v4.3 约束降噪

  • 过滤 <10 字、>800 字、问号结尾、无动作动词/祈使词的句子。
  • 去近似重复;候选超过 8 条时按 hard/imperative/action/长度/scope 打分,只留 8 条。
  • 真实 COMPINT 15 条抽取:平均 8 条、命中 10/15,噪声仍偏高。

v5.0 LLM 语义约束提取

  • 新增 llmExtractConstraints():一次轻量 LLM 调用选择长期有效约束;正则提取器保留为 fallback。
  • 专属 Prompt 明确排除"写一篇文章/写一个长脚本"这类一次性生成请求。
  • 最多 2 次尝试,全部失败才回退正则。
  • COMPINT 15 条:首轮 14/15,重试后 15/15;平均约 1.93 条。
  • 后置过滤排除 请帮我/能不能/是否/有没有/如何/为什么/我想/我希望 等请求语气。

v5.1 单调度器与全自动 E2E

  • autoGraft 收敛为唯一 agent/pre-step 调度器,turn-stopping 与 session/event 不再触发迁移。
  • idle | grafting | done 三态;grafting / done 直接 reject。
  • 创建 target 之前先写 .transferred / .retired,失败回滚状态与文件。

E2E:

基准A 组(原生 compaction)B 组(本插件)
COMPINT 15 条模型探针15/1515/15
RULER 5 条多跳4/55/5
Task Status 5-Todo—正确答出剩余 api / ui / test

诚实标注:COMPINT 上 A/B 打平,B 无显著优势;RULER 上 B 领先 1 条。样本量小,不可外推。

v5.2 补丁三:旧会话交接卡片

  • 旧会话被 transferred/retired 拦截前,append 一次性 assistant/message 交接卡片。
  • 卡片包含目标会话、迁移时间、已保全内容与"请前往新会话继续"。assistant/message 是 DSH 会渲染的 surface;早先尝试的 developer/message 在桌面端不可见。
  • 卡片 stream: [],不带 sourceEventSeqs,不写 tool/result。
  • 旧会话标题更新为 [已交接] → <targetSessionId>。
  • 若拦截发生在无 open step 的 pre-step,补一个 card-only turn + step/start,只补 step/end,由 agent loop 收 turn/end。
  • 真实桌面端已确认:卡片可见、标题变为 [已交接]、旧会话输入被拒绝。

v5.2 崩溃事故与架构修复

事故:把 detached target 用 sessions.enter() / sessions.announce() 塞进 live store,并向 live target 追加 source 的 request/header、permission/preset、sandbox/mode、approval/policy。DSH 重启时恢复这些"半活"会话直接崩溃,安全模式也无法启动。

架构结论(DSH 三层不可混用):

持久化层  sessionPersistence(磁盘事件日志)
运行层    agent loop → sessions live store → 模型/工具/权限
UI 层     workspaceRegistry → workspace.sessionIds

修复:

  • 移除 sessions.enter / sessions.announce,target 保持 detached prepared。
  • 移除 pendingRuntimeMeta、applyPendingRuntimeMeta,不再伪造 request/header 与权限事件。
  • 模型/工具/权限由 target 打开时的 agent loop 真实建立;迁移只继承 agentPreset 与持久意图(见下节)。
  • stableCtx.get() 改为「先查启动快照、回退 live ctx」,修复 workspaceRegistry 晚绑定导致 autoGraft 路径 attach 失败。

隔离验证(headless + --patch):

  • 新 target 自动进入工作区,无需手改 workspace.json。
  • 连续两次重启并恢复该 target:exit=0,无崩溃。
  • 骨架事件无 surfaceOp;seeded assistant/message 的 stream 为 [];无 sourceEventSeqs。

v5.2 运行态意图迁移(读 DSH 源码后的正确做法)

崩溃后的结论不是"运行态不能迁移",而是必须迁 DSH 自己认可的持久意图,而不是伪造请求头。

对照 DSH 源码(dsh-api-session-controller、dsh-permission-presets、dsh-compaction-basic、dsh-session-reference、dsh-workspace)后确定:

事件归属 projection能否迁移
model/selectionmodelSelection(lastUsed / pending)✅ 迁,这是用户的选择意图
permission/presetpermissions.preset✅ 迁
sandbox/modepermissions.sandbox✅ 迁
approval/policypermissions.approval✅ 迁
request/header一次模型请求的 epoch header❌ 绝不复制
sessions.enter / announcelive store 生命周期❌ 插件不得调用

实现:extractRuntimeIntent() 从完整源日志读取这四类事件,buildInjectionEvents() 在 turn/end 之后写入 target seed,位置与 DSH 自己写这些 knob 的位置一致,全部 surface: false。

autoGraft 的 snapshot 会截断到最后一个 turn/end(避免切到 open turn),而 knob 事件恰在其后,因此 autoGraft 单独从完整事件里提取 intent 再传入。

验证(隔离 headless):

src  session-lab6-src      model/selection = deepseek-account/deepseek-flash
tgt  lab6-mvxlqnqw-e8rqvu  ... turn/end, permission/preset, sandbox/mode,
                               approval/policy, model/selection
                           v4 validator PASS,连续两次重启 exit=0

决定性验证——用 DSH 自己的 applyModelSelectionProjection 折叠 target 日志:

lastUsed = { provider: qodercn,          model: dfmodel }         ← 该次运行 profile 默认
pending  = { provider: deepseek-account, model: deepseek-flash }  ← 迁移进来的意图
next     = deepseek-account / deepseek-flash                     ← UI 下次装配采用

即:即使 profile 默认是 qodercn/dfmodel,target 仍按 source 的 deepseek-account/deepseek-flash 恢复。

限制说明:headless 一次性运行在 agentOptions 里直接注入 profile 默认 provider/model,不经过 modelSelection projection,所以 CLI 单跑不会体现该意图;桌面端经 api-session-controller.selectionFor() 读取 pending,会生效。

v5.2 退休状态按 profile 隔离

.retired.json 决定 pre-step 拒绝哪些会话。两个 profile 共用一份文件,意味着 lab 里的测试退休会静默拦掉 desktop 的真实会话,因此这是正确性要求,不是整洁性偏好。

路径解析顺序(resolveStoreDir()):

顺序来源结果目录
1ctx.paths.data服务声明的数据目录
2DSH_PROFILE_DIR<profile>/data/dsh-graft-slice
3DSH_HOME + DSH_PROFILE<home>/profiles/<profile>/data/dsh-graft-slice
4插件目录共享,激活时打 warn

注:paths 不在当前 Service 目录中(cordis_inspect_query 已确认),所以实际生效的是第 2 项——启动器总会注入 DSH_PROFILE_DIR(本机观测值 <home>\.dsh\profiles\desktop)。

激活阶段就会 mkdirSync(dataDir, { recursive: true }),把"目录不可写"提前到启动时暴露,而不是迁移中途失败。

覆盖测试:test/store-isolation.test.mjs 验证 A/B 两目录互不泄漏、损坏文件不阻断激活、无 paths 时走 DSH_PROFILE_DIR。

v5.2 补丁四:超长代码块指针化

assistant/message 里直接被写出的 fenced code block,超过 200 行或 8,000 字符时不再全文进 tape:

[codeblock seq=4 lines=4-303 preview]
const row0 = 0;
…(前 10 行)
…[294 more lines; call graft_read_result({"sourceSessionId":"<sourceSessionId>","seq":4,"lineRange":{"start":4,"end":303}})]

graft_read_result 现在同时接受两种指针:

  • [tool name=xx seq=YY] → 读 tool/result
  • [codeblock seq=YY lines=A-B] → 读 assistant/message

并新增 lineRange 参数(1-based,闭区间),返回 lineRange: { start, end, totalLines }。

关键正确性细节:指针里的行号是整条消息的绝对行号,不是代码块内相对行号。否则 lineRange 会静默偏移——首版实现正是犯了此错,被真实 E2E 抓到并修正。同理,body 尾部那个位于闭合围栏前的换行是"终止上一行",不是"开启新行",行数统计必须去掉它。

真实 E2E(300 行内联代码块):指针输出 lines=4-303;lineRange {start:250,end:255} 返回 const row246 … const row251,与源日志第 250–255 行逐字一致;{start:4,end:6} 返回 row0…row2。

只处理 assistant 正文;write_file/edit 产生的代码已有工具指针,不重复指针化。

红线合规

红线实现
禁止 ctx.sessions.fork()代码中不存在;测试静态扫描 + 假宿主无 fork
骨架不带 surfaceOpappendLogOnly() 每次 append 后断言 surfaceOp === undefined
第一条 surface 必须是 system/messagebuildInjectionEvents() 在 step/start 后立即注入;assertColdLog() 再次断言
system/message、user/message、assistant/message 带 surfaceOp:'append'appendSystemMessage() / appendUserMessage() / appendAssistantMessage() 断言
assistant/message 的 stream: [],禁 sourceEventSeqsbuilder 固定 stream: [];append 后与冷读后双重断言
不单独 append tool/result只把结果转成指针文本;冷读断言无 tool/result
source.kind 不为 pluginextractSourceTemplate() 跳过 kind:'plugin';兜底 plugin:graft
结构性畸形 turn 拒绝locateBoundary() 对「open turn 内再 start」与「无 start 的 end」抛错
退休拦截不写源日志内存 Set + 持久化 .retired.json/.transferred.json;agent/pre-step 返回 { kind:'reject', reason }
不伪造 request/header代码中无 request/header 写入;运行态只迁 model/selection 与三个权限 knob
不调用 sessions.enter / announce代码中不存在;target 始终 detached prepared

v6.3.0 四个物理防线

前几版解决的是"语义是否接得上",v6.3 解决"物理动作是否正确"。

模块一:Safe Harbor(安全停泊点)

问题:模型正在跑长 tool/call 或流式输出时强切,target 会继承一个悬空调用——有 call 没有 result,模型看到无法解释的工作。

实现:checkSafeHarbor(events) 扫描整个日志:

条件判定
tool/call 无匹配 tool/result不安全
assistant/chunk / assistant/attempt 出现且未闭合不安全
其余安全(quiet boundary)

不安全时置 pendingGraftSources 标记并返回,下一个 turn/end 是天然安静边界,届时重试。

模块二:多模态与附件指针

问题:buildTape 只保留文本,用户上传的图片/文件在 target 里彻底消失。

实现:extractAttachmentPointers(message) 识别 image / file / attachment / document / audio / video 块,提取 path、hostPath、url、source.path、attachment.path 或引用 id,转成:

(回顾:用户在此处上传了一个图片(bug.png),保存路径为:<workspace>\shots\bug.png,必要时请直接读取该路径)

无路径时如实说明"记录里没有留下可读取的路径",不编造。指针挂在当前 turn 的 assistant 块上,与工具指针同样自然。

注:本机 572 份真实日志中没有附件块(内容类型只有 text / reasoning / tool-call),因此该模块按 DSH 的消息块规范实现,并用构造 fixture 覆盖。

模块三:自适应切片深度

问题:recentTurns: 5 硬编码。Debug 需要更多上下文,写作需要更少。

实现:calculateAdaptiveDepth(events, settings) 看最近 6 轮的形状:

信号含义
tool calls / turn高 = 调试
报错比例高 = 调试
代码块占比高 = 调试
assistant 文本长度长 = 生成

三档结果:

debug      → depth = max(6, 0.9*max)    例:8
generation → depth = min(4, min+1)      例:4
balanced   → depth = 5

报告新增 adaptiveDepth: { depth, mode, reason },例如:

generation-shaped: 0.0 tool calls/turn, 0% code, long prose

真实日志验证:v4 校验器输出 real-log PASS events=22 recent=7 older=4——自适应在真实会话上选用了 7 轮。

模块四:迁移确认阀

问题:静默迁移会让正在专注打字的用户"被切走"。

实现:新增 graftMode: 'silent' | 'confirm'(默认 silent)。

confirm 模式下的水位处理:

达到 75% → 不迁移,pre-step 返回 reject:
             "上下文即将满载,是否由系统进行无缝迁移?
              (回复"确认"立刻迁移,其它回复取消)"
用户回"确认" → 执行迁移,同样走 Safe Harbor 检查
用户回其它   → 取消提示,水位可再次触发

confirmTtlMs 默认 10 分钟,超时自动失效,避免陈旧提示堆积。

v6.4 产物索引与工具可用性降级

v6.3 的真实长会话盲测暴露的最后一个缺口是 A5(无感)只做到一半:新窗口知道"发生过什么",但不知道"现在的工作落在哪些文件上",于是第一条动作是自己扫工作区。

一、[Workspace Files] 产物索引

从源窗口的 write / edit 调用里派生,按最近一次操作排序,作为 chunk workspace-files 注入:

[Workspace Files]
本次窗口内我写过/改过的文件(按最近一次操作排序,工作目录 <other-project>):
- recovery\raw\window-filter-3.mjs(最近一次:write,seq=15018)
- dsh-rollover-snapshot\tests\round52-delegation.spec.ts(最近一次:write ×2,seq=14667)
本次窗口共有 327 个文件,这里只列最近 20 个;更早的同样能用 graft_read_result 按 seq 回读。
以上是写入记录,不是磁盘校验结果;需要内容时用 graft_read_result 按 seq 回读,不要再盲目列目录。

三条硬规则(都有测试守着):

规则为什么
有上限就声明上限(共有 N 个 / 只列最近 M 个)静默截断的列表会被读成"事情就这些",下一窗口据此认为没有别的产物
最近一次报错的路径标 未确认落盘渲染函数不 stat 磁盘,不能把"调用过"说成"文件在"
页脚只说"写入记录,不是磁盘校验结果"旧文案写"当前磁盘上真实存在的产物"——这是一句没做校验的断言

失败判定吃的是真实 v4 形状(data.message.toolCallId / data.message.isError),不是 data.toolCallId。 这一点是实测抓到的:按扁平形状写会编译通过、用手写 fixture 也能过,但在真实日志上永不触发(防线存在但已死)。

真实语料证据(本机 575 份持久化日志,只读扫描):

指标值
含 write/edit 的路径3,526
最近一次尝试报错的路径38
会因此出现 未确认落盘 的日志22
例:534030e7…(cwd D:\demo-workspace)- work\audit\residual\e5b_minification.py(最近一次:edit ×5,seq=479,最近一次报错,未确认落盘)

同一份 11 MB 真实语料(session-51ba018f…,15,062 事件):write/edit 调用 475 次、去重 327 个路径、块内列 20 个 → 声明省略 307 个。

二、F3 工具可用性降级

tape 里的指针句原本一律写成"我刚才已经调用 X 完成了这一步"。若 X 是源窗口独有、新 profile 没挂载的工具,模型会照着调用一个不存在的工具。

现在切片时读取宿主实时工具注册表,目标没有的工具改成:

(历史记录:使用了当前环境不支持的工具 spawn_teammate;原始输出仍可用 graft_read_result 读 seq=1115)

同一份真实语料(把可用工具限制为 pwsh/read/write/edit/grep/glob/todo_write/job_output):20 条指针被降级,降级后仍被写成"可调用"的 0 条。

三、盲测口径的变化

goalMessage 的旁白从"当前磁盘的核心文件状态已同步"改为"本窗口写过/改过的文件已列在 [Workspace Files] 区块,请以此为起点继续"——因为索引是写入记录,不是磁盘快照。

v6.3.0 测试(historical — 当前套件见上表)

95 tests / 95 pass(v6.3.0 快照,historical;新增 10 条)

新增覆盖:

safe harbor: 未返回结果的 tool call 阻止切割
safe harbor: 已返回结果的 tool call 是安全边界
safe harbor: 流式输出阻止切割
attachments: image 块生成可读路径指针
attachments: 无路径的 file 块如实说明
attachments: 纯文本消息不产生指针
adaptive depth: 调试型会话保留更深窗口(>=6)
adaptive depth: 散文型会话保留更浅窗口(<=4)
adaptive depth: 两种密度结果确实不同
confirm mode: 水位达到时先询问而非直接迁移

v6.0 日志损坏事故(必须读)

插件在真实桌面端把一个 7,531 事件、92 轮、6.6 MB 的用户会话写成了 corrupt。

根因

appendTransitionCard 为写交接卡片自己补了 step/start,且 step 号硬编码为 1:

7525 step/end   {turn:92, step:2}
7526 step/start {turn:92, step:1}   ← 插件写的,step 号回退
7527 assistant/message {turn:92, step:1}
7528 session/title
7529 step/end
7530 turn/end   {turn:92}

v4 要求同一 turn 内 step 严格递增,1 出现在 2 之后即被判非法:

SessionFormatError: step/start does not match the open turn and next step

整个日志因此无法加载,桌面端报 历史加载失败。

修复

数据修复:只删插件写的 4 个事件(7526–7529),保留 DSH 自己的 turn/end,重排 seq。

修复后 7,527 events / 92 turns
V4_VALIDATOR_PASS

原始文件已备份为 session.v4.jsonl.zstd.bak-before-stepfix。

代码修复(零伪造原则):Agent Loop 是 lifecycle 事件的唯一 writer,插件不得代写。

规则实现
绝不写 turn/start / step/start / turn/end / step/endvalidateAppendIsLifecycleSafe() 对这些类型直接返回 ok:false
只在已有 open step 时写openStepOf() 返回 undefined 就放弃,不补 step
只写 developer/message 与 session/title两者都是 log-only,不受 lifecycle 约束
写前预校验按 v4 规则重放 turn/step 序列,不合法就跳过并打 warn

代价:已关闭的会话看不到交接卡片。静默优于损坏日志。

回归测试

test/zero-forgery.test.mjs(5 条):

open step 3 绝不收到更小的 step 号
已闭合日志不接收任何事件
open turn 但无 open step 时不写
只追加 developer/message 与 session/title
非法前缀被预校验拒绝并记录 warn

v6.0 剧本化唤醒

注入内容从"说明书"改成"续演剧本"。轻量模型读到结构化材料时倾向于复述字段,读到叙事时才会继续干活。

位置v5.2v6.0
Goal SeedMarkdown 列表 **当前目标**:…第一人称旁白 【系统交接】上一段对话因为上下文满载…请顺接前文的思路直接继续
PendingState上一轮我们排除了 Y;目前的判断是 X内心独白 我刚刚排除了 Y。我现在的判断是 X。接下来我需要 Z。
工具指针(注:此处调用了 write 工具…)历史回忆 (回顾:刚才我已经调用 write 完成了这一步,结果已折叠…读 seq=123)
结尾pending-state 即结束追加执行死命令:系统强制指令:不要复述上文…立刻调用相应工具执行第一个动作。

死命令作为最后一个 assistant 块,位置在全部材料之后、step/end 之前。

源句子自带的前缀(- 下一步:、当前假设:)在拼进独白前会被剥掉,否则会读出"接下来我需要 - 下一步:…"这种嵌套标题残渣。

v6.0 补丁一:草稿捕获与接管

用户在旧会话被拦截那一刻正在输入的文本不该消失。

旧会话 reject 前 → 捕获最后一条 user 文本 → .graft-drafts.json
target 首次输入 → 合并成一条 user/message → 立即删除

合并格式:

[系统提示:用户在上一个会话被拦截前,正在输入:「把 auth 模块的 token 刷新改成」]
[用户新输入:「继续。」]

请把这两段当成同一个连续意图来处理。

只生效一次:拿到就删,避免用户每轮都看到同一段旧草稿。

v6.0 关键修复:dataDir 必须显式配置

桌面宿主不向 Electron 主进程导出 DSH_PROFILE_DIR / DSH_HOME——那两个变量只存在于工具沙箱的子进程环境里。真实桌面端 E2E 的证据是状态文件落在了插件目录:

<data-dir>\.transferred.json
<data-dir>\.retired.json

而 profiles\desktop\data\dsh-graft-slice\ 是空的。也就是说环境推断的三级降级全部落空,最终回退到插件目录,所有 profile 共享一份状态。

修复:新增 config.dataDir,优先级最高:

顺序来源
1config.dataDir(显式,桌面端唯一可靠手段)
2ctx.paths.data
3DSH_PROFILE_DIR
4DSH_HOME + DSH_PROFILE
5插件目录(共享,激活时打 warn)

desktop profile 现已配置:

dataDir: "<home>\\.dsh\\profiles\\desktop\\data\\dsh-graft-slice"

v6.0 真实 E2E 抓到并修复的缺陷

指针用了字面量占位符:graft_read_result({"sourceSessionId":"<sourceSessionId>"…})。模型只能从交接旁白反推源会话 id,推不出就读不到。现在直接写入真实 id。

LLM 结果覆盖正则兜底:系统头里有 2 条约束,随后注入的却只有 1 条——「只用要点列表」 被 LLM 的较短结果顶掉。现在两者取并集(按归一化文本去重),并在收窄时打 warn。

v6.0 桌面端状态

迁移产物 session-v6-desktop-1 校验:

events 20, turns 1, v4 validator PASS
title:  [接续] 检查下大量会话发生这样的错误是为何,并尝
goal:   【系统交接】…(第一人称旁白,无 JSON)
chunks: 摘要 / Subagent / 正文(含回忆指针) / 内心独白 / 死命令

session/end-seed 与 request/header 均不存在,说明 DSH 尚未真正打开过该会话——它处于待唤醒状态,需要在 target 里发一句话才会进入模型。这是预期行为,不是注入失败。

v5.2 补丁二:异步约束提取

迁移关键路径不再等待 LLM。原来 await llmExtractConstraints 会随源历史增长而线性变慢,直接推高「触发到看到新会话」的延迟。

新流程:

迁移开始 → 正则 extractConstraints 生成 FAST_CONSTRAINTS
         → 建 target / 斩断旧会话 / flush 落盘      ← 用户此刻已可用
         → 后台 llmExtractConstraints(不 await)
         → 成功则写 .pending-constraints.json
         → target 下一次 pre-step 注入一次

失败处理:LLM 挂了就保留正则结果,迁移不回滚。约束质量下降是次要问题,迁移已经成功。

注入通道用 agent/pre-step 返回的 messages,与 dsh-session-reference 注入跨会话快照的方式一致。不写日志事件——自己开 step/start 会占用 step 序号,v4 要求 step 严格递增,agent loop 随后自己的 step/start 就会被拒(step/start does not match the open turn and next step)。这个坑是真实 E2E 抓到的。

注入消息的角色必须是 user:DSH 要求 developer 角色与 developer/message 事件同时出现,而 pre-step 的 messages 会投影成 user/message。用 [系统提示] 前缀让人工可读、也让模型知道这是背景而非新指令。

v5.2 注入格式拟人化(面向 Flash 等轻量模型)

结构化标签会让小模型复读或试图重放,因此注入内容改写成叙述:

位置之前现在
工具指针[tool name=write seq=123 expand_result(...)](注:此处调用了 write 工具,结果已省略;需要细节时用 graft_read_result 读取 seq=123)
工具报错同上(注:调用 pwsh 报错,Exit Code 7;可用 graft_read_result 读取 seq=123 的完整输出)
pending state当前假设:X / 已排除:Y / 下一步:Z上一轮我们排除了 Y;目前的判断是 X;接下来的主要任务是 Z。
goal seed{"kind":"persistent-capability-seed",...}Markdown 段落:当前目标 / 工作目录 / 源会话 + 「请先从上次停下的地方继续」
标题可能带 {"kind" 或 [检测到结构化残留则回退 [接续] MM-DD HH:MM

配套修正:指针不再独占一行,所以 pointerLines() 改为内嵌扫描(pointerSentences()),工具名也从「调用了 X 工具」中提取——否则 olderToolNames 会退化。摘要清洗用整句匹配,避免把指针替换成空格后留下「需要细节时用 )」这种残句。

真实 E2E(240 行内联代码块 + 两条中文约束):

迁移报告 constraintSource = regex-fallback   ← 迁移时只有正则,符合设计
后台 3 秒内写入 .pending-constraints.json:
  所有回答只用要点列表,不要写段落。
  涉及数字时必须写成英文单词。
target 下一次 pre-step 注入一次,模型回答:
  「Standing constraints from the source session's user messages, verbatim: …」
v4 validator PASS(23 events, 2 turns)

开放 turn 的处理(v5.2 修正)

早期实现把「最后一个 turn 未闭合」当作致命错误:

the last turn (91) has no matching turn/end; refusing to slice without a stable boundary

真实桌面端暴露了这个设计缺陷:从任何会话里调用工具,调用者本身就在那个敞开的 turn 中,因此它永远无法闭合自己。 结果是手工路径几乎 100% 失败,而 autoGraft 因为用截断快照一直正常。

修正后 locateBoundary() 区分两类情况:

情况处理
末尾有未闭合 turn(正常开放尾部)丢弃该 turn,切到最后一个已闭合 turn/end,返回 droppedOpenTurns: 1
turn/start 落在已开启的 turn 内仍抛错——日志本身不一致
turn/end 没有匹配的 turn/start仍抛错——日志本身不一致

allowOpenTail: false 可恢复严格模式。

真实 6.6 MB 日志验证(session-3afa4b95…,7,505 事件,人为追加 turn 92):

boundary { boundarySeq: 7504, lastTurn: 91, turnCount: 92, droppedOpenTurns: 1 }
tape turns 74, last tape turn 91, pointers 637

语义代价:当前这一轮不进入 target 的「骨架」。这是正确取舍——切片需要已闭合的稳定边界,而正在进行的 turn 既未完成、也仍在增长。

v7.0 B1 / v7.4 P0-3b 更新:开放轮的内容不再丢弃。骨架仍切在最后一个 turn/end,但开放轮里的用户指令、助手正文和已完成的工具结果会以 open-turn-carryover 块注入在磁带最前面(工具结果只带指针,用 graft_read_result 回读)。v7.4 修复了「快照在 boundary 处预截断、导致携带块永远是空尾巴」的事故;v7.4c 又补上了只有用户消息、模型还没回答的开放轮——这种形状此前会被 buildInjectionPlan() 整块丢掉。回归测试:test/v74b-open-turn-carryover.test.mjs、test/v74-breakpoint-priority.test.mjs。

工具

graft_slice_and_inject

名称类型必填说明
sourceSessionIdstring是已闭合的源会话;最后一个 turn 必须被 turn/end 关闭
newSessionIdstring是新会话 id;不得已存在,且不等于源会话

返回:ok、sourceSessionId、newSessionId、cwd、boundarySeq、sourceEventCount、turns、recentTurnCount、olderTurnCount、injected、pointers、constraints、constraintSource、taskStatus、subagentReports、workspace、eventSeqRange、sourceTemplate、skeleton、durability、retired、warnings。

graft_read_result

名称类型必填说明
sourceSessionIdstring是拥有该 tool/result 或 assistant/message 的源会话
seqinteger是指针中打印的 seq
lineRangeobject否{ start, end },1-based 闭区间,用于代码块指针

返回:found、seq、text、truncated、totalChars,以及(带 lineRange 时)lineRange: { start, end, totalLines }。

配置

cordis.patch.yml 默认:

recentTurns: 5
oldTurnSummaryChars: 600
maxTurnChars: 0          # 0 = 不截断近期 turn
maxTotalChars: 0         # 0 = 不设总预算
retireSource: true
verifyReadBack: true
skeletonTurn: 1          # rc.5 必须为 1
skeletonStep: 1          # rc.5 必须为 1
autoGraft: false
autoGraftAtRatio: 0.75
modelDrivenGraft: true           # v7.6: 70% 先让模型自己收尾并切片
modelDrivenGraftAtRatio: 0.70    # 预警水位(须低于 autoGraftAtRatio)
contextWindowTokens: 0   # 0 = 从 request/context 推断
autoGraftIdPrefix: auto-graft
autoGraftSessionIds: []  # 空 = 所有会话
autoGraftKeepAliveMs: 0  # 仅测试用
constraintProvider: ''
constraintModel: ''

长会话建议显式设置预算,例如 maxTurnChars: 12000、maxTotalChars: 60000。本机 317 份真实日志扫描结果:299 份可切片,16 份 open-turn,2 份无闭合 turn;平均 tape 约 14k 字符,最大约 199k 字符;不设预算时请自行评估模型上下文。

构建与测试

cd <plugin-dir>

# 1) 构建 index.js(Node >= 22.6 才带 stripTypeScriptTypes;运行时只需 Node >= 20)
node scripts/build.mjs

# 2) 538 个测试
node --test test/*.test.mjs

# 2b) 文档与代码一致性(版本、构建新鲜度、工具清单、测试计数、功能状态)
node scripts/check-docs.mjs

# 3) 端到端演示:自动挑一份最近的、已闭合的真实日志
node scripts/demo.mjs

# 4) 指定源会话
node scripts/demo.mjs --source session-demo-source --new session-graft-demo

# 5) 只读扫描本机全部真实日志
node scripts/scan-real-logs.mjs

# 6) 可选:用从 app.asar 提取的真实 v4 校验器再验一遍
$env:DSH_VALIDATOR_NODE_MODULES = "$env:TEMP\dsh-format-check\node_modules"
node scripts/validate-real-format.mjs

安装与调用

plugin_manager(action: "install_bundle", target: "D:\\work\\demo\\dsh-graft-slice")

或手动挂到 profile 的 cordis.patch.yml:

- insert:
    - id: dsh-graft-slice
      name: dsh-graft-slice
      config:
        recentTurns: 5
        retireSource: true
        verifyReadBack: true
        skeletonTurn: 1
        skeletonStep: 1
        autoGraft: false

然后调用:

graft_slice_and_inject({ sourceSessionId: "session-…", newSessionId: "session-graft-…" })

刷新 Web UI 会话列表,打开/恢复新会话。若要从源会话立即退休,源会话的下一次 agent/pre-step 会返回 { kind: 'reject' };如果不想停掉源会话,把配置改为 retireSource: false。

冷恢复验收:

  1. 重启 DSH,打开新会话,不得出现 SessionFormatError。
  2. 新会话第一轮应以第一人称复述目标、进度、下一步。
  3. deriveMessages() 中骨架事件不产生消息,注入的 assistant 消息作为顶层消息投影。
  4. 在新会话调用 graft_read_result({ sourceSessionId, seq }) 能取回旧工具结果。

限制

  • retiredSessions 内存集合由 .retired.json 支撑,按 profile 隔离(见上文);文件损坏时静默降级为空集,不会阻断激活。
  • 源会话必须有绝对 cwd;新会话使用与源会话精确字符串相等的 cwd,不做 normalize/symlink 归一化。这是跨会话工具授权的前提。
  • 不注入 tool/result 事件;工具结果只以指针文本存在。
  • 不复制源 stream;所有合成 assistant 消息都是 stream: []。
  • graft_read_result 按规格绕过工具层授权;请用工具策略限制调用方。
  • manifest.json 的 permissions 是声明性元数据;DSH 0.2.1-rc.5 不按插件 manifest 做权限强制,实际能力来自注入的 Service。
  • 代码块指针只处理 assistant 正文里的 fenced block;缩进式代码块与 write_file/edit 产物不处理。
  • 草稿转发与异步约束精修已实现:源被 reject 前捕获最后一条 user 文本写入 .graft-drafts.json,target 首次输入合并一次即删除;迁移关键路径只用正则约束,LLM 精修在后台完成后排队,由 target 下一次 pre-step 注入一次。迁移本身仍是同步的(建 target、落盘、斩断之间不并发)。
  • source 独有的工具在 target profile 不存在时,指针会被降级成"历史记录:使用了当前环境不支持的工具 X",只保留 graft_read_result 的 seq 取回路径(见 v6.4 F3)。降级依赖宿主实时工具注册表:注册表读不到时按"全都可用"处理,指针保持原样——宁可让模型尝试一次,也不谎称工具不存在。
  • [Workspace Files] 是写入记录,不是磁盘校验:本窗口写过/改过的路径按最近一次操作排序,最近一次报错的标 未确认落盘,超过 20 条的按总数声明省略。文件若被后续步骤删除或改名,索引不会知道。