# dsh-trilogy — 设计说明 > 一份按项目隔离、纯 Markdown、零依赖的跨会话记忆方案。 > 核心是三个文件的分工,以及 **由 harness 而不是由模型自觉** 来保证的三件事: > 自动创建、自动加载、自动记录。 --- ## 1. 为什么要做原生插件,而不是装现成的技能 也就是:**不会**自动建文件、**不会**自动加载、**不会**自动记录 —— 全靠模型自觉。 用户要的是机制保证: | 需求 | 技能方案 | 本插件 | |---|---|---| | 新建项目自动创建三个文件 | ❌ 要人喊 `/memory-init` | ✅ 会话启动时自动 scaffold | | 每个会话开始加载三个文件 | ❌ 靠模型记得读 | ✅ 自动注入上下文,去重防重复 | | 进展分类记录进三个文件 | ⚠️ 靠模型自觉调 `checkpoint` | ✅ 收尾兜底 + 显式工具 | --- ## 2. 三个文件的语义 全部在 `<项目根>/memory/` 下。 | 文件 | 职责 | 写入方式 | |---|---|---| | `PROJECT.md` | 项目**现在**是什么 | **就地编辑**,控制在一屏内;与代码冲突时代码赢,并在同一轮修正该文件 | | `DECISIONS.md` | 为什么是这样 | **只追加,最新在最上** | | `SESSIONS.md` | 发生了什么、什么时候 | **只追加,最新在最上** | ### PROJECT.md 固定五节(顺序固定) 1. **这是什么** — 一段话 2. **怎么跑和怎么测** — 可复制粘贴的确切命令 3. **东西都在哪** — 只有重要的路径,一行一个 4. **State** — 什么能跑、什么在做 5. **坑** — 看着像 bug 但其实是有意为之的,以及「修」了会坏什么 空节写 `暂无`,不写占位废话。旧版的英文标题(`What this is` 等)在会话打开工作区时**就地改名**,只动标题行。 ### SESSIONS.md 条目形状 ``` ## YYYY-MM-DD Done: what is now true, and how it was verified Open: what is unfinished Next: the one concrete next action ``` **`Done` 没有验证说明就只是主张,不是记录** —— 没验证就如实写没验证。 ### DECISIONS.md 条目形状 ``` ## YYYY-MM-DD — <选择,一行> Chose: 选了什么 Over: 否决了什么,为什么 Because: 逼出这个选择的约束 ``` --- ## 3. 「什么才配占一行」——唯一的判据 对每一条候选内容只问一句: > **没有这条,未来的会话会不会浪费时间、或者重犯同一个错误?** 配占一行: - 踩坑踩出来的约束,以及违反它的代价 - 一个修复,**按成因记录而不是按症状** - 某处看着不对但其实是**有意为之**、不能被「修好」 - 工作停在哪、以及那一个具体的下一步 不配: - 代码、测试、commit 历史已经写明的任何东西 - 「我试了什么」的过程叙述 - 只在这轮对话里有意义的细节 **不合格的候选是丢弃,不是删短。** --- ## 4. 插件的四个行为 ### 4.1 会话开始:自动 scaffold(新项目) 1. 从会话工作目录向上找 `.git` 定项目根(找不到则用工作目录) 2. 若 `memory/` 或三个文件缺失 → 从模板创建,已存在的**一律不覆盖** 3. 若项目根的 `AGENTS.md` 里没有 Memory 段落 → 追加 boot block(**保留原有全部内容**) > 「新建项目自动建文件」靠这一步保证,不依赖模型。 ### 4.2 会话开始:自动注入(保证信息最新) 把 `PROJECT.md` + `DECISIONS.md` 全文、`SESSIONS.md` 最近 N 条,注入成模型可见的上下文。 - **一次会话只保留一份**,按内容 SHA-1 去重 —— 文件没变就不重复注入,**KV cache 友好** - 有字节预算(默认 **160 KB**),**生效值再按路由窗口封顶** `min(配置值, 窗口 × 16%)` —— 固定字节数在两头都是错的:太小扛不住成熟工作区,太大又会让小窗口的模型被一个块吃掉上下文。 超预算时 `PROJECT.md` 优先(它是当前状态),`SESSIONS.md` 先裁 - **头部常驻一行账目**(「本次注入 / 文件实际」)—— 早期只在丢东西时才出 footer,结果是 成熟工作区早已撞到天花板却没人发现(实测 `interface-adapter`:64 KB 预算下**日志整段进不去**, 一条都没有)。账目必须每次会话都看得见 - 文件在会话中被本插件改动后,**只在末尾追加一行 ~200 字节的提示**(「记忆已更新」), 不重发整块、也不改写历史 —— 改写历史中段会让 prompt cache 从那里到结尾全部失效 (实测:1.3% 的请求吃掉 58% 的全价输入,平均贵 93 倍) #### 4.2.0 名额怎么分:按种类封顶,按区域分配 一个工作区就是一个项目,但**一个项目里的事可以很多**。实测本工作区的 91 条日志横跨 4~6 个 互不相干的主题(插件本体、DSH 部署排障、第三方插件评估、网络环境、搜索技能…)。而「取最近 15 条」在这种日志上必然偏斜:最近 15 条里有 8 条是插件开发、5 条是别的插件,**一条网络相关的 都没有**。所以名额分两级: **第一级 —— 按种类封顶,余量给日志。** `PROJECT.md` 最多占预算的 35%、`DECISIONS.md` 20%, 剩下全给日志。用「封顶」而不是「配额」:某个文件用不到自己的份额时,**余量流向日志**而不是 浪费掉。 其中 `PROJECT.md` **再按自己的五个小节分别封顶**(份额 ÷ 5)。这一步比整体封顶更有效:实测 一个 57 KB 的 `PROJECT.md`(其中一节就快 30 KB)整体封顶只降到 56 KB,**按节封顶直接降到 18 KB** —— 因为小节点原样保留,只切超长的那一节,而且切处在注入文本里明说 (「本节已超出注入上限 N KB,请就地精简」)。这是唯一能压住 `PROJECT.md` 不偏离它自己 「一屏内」契约的东西。 **第二级 —— 日志的名额按区域分。** 条目可以在标题上带区域(`## 2026-09-18 · 网络`), 分配规则三条: 1. **每个区域保底 1 条** —— 没有这条,聊到安静的区域就一条都看不到 2. 其余按**注意力权重**分:`Σ 0.5^(条目年龄天数 / 14)` —— 最近干得多的多拿 3. **单个区域不超过公平份额的 2 倍** —— 没有这条,一个热门区域会吃掉全部日志 **区域必须是粗粒度闭集**(最多 4 个 + `通用`)。分类由模型在写入时给出(它上下文最全,且这跟 它本来就在分的 project/decisions/sessions 是同一件事,**零额外模型调用**);插件只负责把它**压粗**: 名字已在集合里就复用(大小写不敏感),已经 4 个了就把第 5 个归入 `通用`,留空也归 `通用`。 **宁可粗,不要碎** —— 一长串只有一条的区域,比一个粗标签差得多。注入块头部常驻一行区域地图 (`区域:插件(21) · 部署(14) · 通用(27)`),模型照着复用,不会越分越碎。 区域是**条目上的标签,不是新文件**:文件仍然是那三个 —— 每多一个文件就多一处「模型忘了更新」, 而更新是这套东西唯一的命门。 #### 4.2.1 为什么更新只能追加,而收拢必须写到 session 上 `agent/pre-step` 的 `messages` **不是会话历史**:宿主用 `inbox.claim(...)` 建输入与 `next()` 的默认值,然后把返回的每一条都 `session.append(..., {surfaceOp:"append"})`。所以在那里返回消息 只能「加」不能「换」。 **日常更新本来就该是「加」** —— 追加不动前缀,缓存友好。只有一种情况需要「换」:**收拢库存**, 即会话里因为老版本累积了多份同样的块。那时必须 `agent.session.append("user/message", 占位, {surfaceOp:{op:"replace", startSeq, endSeq}, sourceEventSeqs:[seq]})` 写到 session 上。收拢只能 **逐条单节点**换:`assertProvenance` 要求 `sourceEventSeqs` 列出区间内所有被遮蔽的节点,而旧块 之间夹着 assistant / tool 消息,一个大 range 会把这段对话一起删掉。占位另打一个 `form` (`trilogy-superseded`),否则会被当成活块反复收拢。 > 「每个会话开始都加载最新」靠这一步保证。 ### 4.3 会话进行中:显式记录工具 提供模型可调用的工具(分类写入 + 读取): | 工具 | 作用 | |---|---| | `memory_checkpoint` | 按上面的路由表把若干条内容分类写进三个文件 | | `memory_read` | 按需读取某个记忆文件(或其中一节) | 路由规则(内置,不靠模型猜): | 内容性质 | 去向 | 写法 | |---|---|---| | 改变「项目是什么」或「怎么跑」 | `PROJECT.md` | 就地编辑,**同时删掉它取代的那一行** | | 本次会话发生了什么 | `SESSIONS.md` | 顶部追加 | | 定下来的选择 + 被否决的替代 | `DECISIONS.md` | 顶部追加 | **删除是受限操作**:只允许「你刚写入的这行直接取代了某一行」的**配对替换**; 其他看着陈旧/重复/错误的内容**不许删**,要写进报告交给用户决定。 这是本插件的删除护栏。 ### 4.4 会话收尾:智能判断兜底 用户选择「**智能判断:值得记才记,控制 token 开销**」,所以: - **不额外调用模型**(不花钱、不慢)—— 复用当前会话里已经跑着的主模型 - 在一轮将要结束时,若判定「这轮干了实事」且「这轮没有记录任何记忆」 → **只挂一个「欠着」的标记**,在**下一次 `agent/pre-step`** 里把那条很短的提醒追加进那一步, 随用户的下一条消息一起被模型看到,由模型自己判断该不该记 - **投递时机就是这个设计的关键**,两条错路都试过并实测否掉: - `steer` 会把当前回合续下去 → 模型对提醒的回复成为这一回合**最后一条**助手消息 - `agent.send(msg, "next-turn", false)` 只是把它停在 inbox,**驱动仍会把它当成活儿、自己开一个 新回合**(实测:`agent/inbox/spliced target=next-turn` 紧接 `turn/end`,紧接着又是 `turn/start`) → 于是「对话结束后又输出一段话」依旧存在 - 两种方式都会让「以回合末条为准」的界面(产物行、回合导航、预览)指向那句「记下了」,真正的 回答被折叠到回合中间、要展开再滚动才看得见 - 护栏:冷却时间、每会话次数上限、无事发生的轮次不打扰 > 这样"值不值得记"由主模型判断(它上下文最全),插件只负责**保证它一定会被问一次**。 --- ## 5. 配置项 配置写在 profile 的 `cordis.patch.yml` 里(按 id 覆盖),**没有图形化的配置界面**,改完要重启 `dsh web`。 | 键 | 默认 | 含义 | |---|---|---| | `enabled` | `true` | 总开关 | | `memoryDirName` | `memory` | 记忆目录名(相对项目根) | | `projectRootStrategy` | `"workspace"` | `workspace` = 工作区即项目;`marker` = 向上找 `projectRootMarkers` | | `projectRootMarkers` | `[".git"]` | `projectRootStrategy: "marker"` 时往上找哪些标记 | | `autoScaffold` | `true` | 缺文件时自动创建 | | `writeBootBlock` | `true` | 往 `AGENTS.md` 追加 Memory 段 | | `bootBlockFile` | `"AGENTS.md"` | boot block 写进哪个文件 | | `injectOnSessionStart` | `true` | 会话开始自动注入 | | `bootstrapWhenEmpty` | `true` | `PROJECT.md` 还空着时,注入「去调研并填上」的指令 | | `injectBudgetBytes` | `160000` | 注入总字节预算(天花板,不是配额);**生效值再按路由窗口封顶** `min(配置值, 窗口 × 16%)`,见 §4.2 | | `sessionEntriesInjected` | `15` | 注入最近几条 SESSIONS 条目(决定日志量的旋钮) | | `nudgeOnTurnEnd` | `true` | 收尾智能判断兜底 | | `nudgeCooldownMs` | `600000` | 兜底提醒冷却 | | `nudgeMaxPerSession` | `3` | 每会话兜底提醒上限(写入成功即清零) | | `sessionsMaxEntries` | `200` | 超过这个条数才把最旧的 SESSIONS 条目搬进归档 | | `projectStaleDays` | `14` | `PROJECT.md` 落后其余记忆文件多少天才提示陈旧;`0` 关闭 | --- ## 6. 边界(明确不做) - 不做向量检索、不做 embedding、不连网络、不起服务 - 不改 DSH 核心 - 不在未经许可的情况下删除用户已有的记忆内容 - 不代替 `AGENTS.md`(两者共存:`AGENTS.md` 管「该守什么规矩」,三个文件管「项目是什么、发生了什么」) --- ## 7. 测试与验证 | 套件 | 数量 | 覆盖 | |---|---|---| | `test/smoke.mjs` | 72 | 宿主半边:scaffold、boot block 幂等、五节完整性、注入与去重、**更新只追加通知 / 重启不误报 / 通知与占位不算活块 / 旧副本收拢**、压缩后重注入、分类写入、围栏代码块、section 就地替换、nudge 触发/冷却/重置/**只欠不投**、**区域保底与配额上限 / 区域闭集与兜底 / 注入账目 / PROJECT 按节封顶**、**忘记工作区只动索引、不碰磁盘**、归档与恢复(含**跨轮顺序**)、搜索、11 条 Web 端点(含导出/导入、陈旧度与忘记)、loopback 围栏 | | `test/client-render.mjs` | 9 | 浏览器半边:每个注册的组件都真的被调用一次,并检查注入的样式表(含 `box-sizing` 与按钮分组) | | `test/client-interact.mjs` | 29 | 浏览器半边:点击 → 请求 → 状态 → 重渲染的完整往返(含「移除段后编辑器不残留旧文本」与「已清除的行才能被忘记」) | 三套都必须在提交前通过。浏览器半边的渲染错误会被 slot 错误边界静默吞成空白页, 所以「能渲染」和「点了有用」必须各有一套测试,缺一不可。