Commit 4075dcf120dc4753ca2492e29debb754f2f560b7
1 parent
35307d15
feat(dream): 步骤 1 与 4 —— 记忆写入不再冒充事实;定义文件加 git 历史闸门
29-DREAM §四 的五步里先落两步。
## 步骤 4:本体定义文件的 git 历史闸门(决策 B.3)
用户定案:soul.md / agent.md **直接落盘**,但写之前必须确认这份文件已经在
git 历史里 —— 别导致直接修改而没有历史。
definition-write-gate.js 两条判据都满足才放行:
① 文件被 git 跟踪;② 该文件没有未提交改动。
判不出来(不在仓库 / git 不可用)一律拒绝 —— fail-closed。
**为什么是「拒绝并报告」而不是「脏就先替它提交一把」**:后者看着更顺手,
但那正是本仓库记过的一类事故 —— 多会话共用同一个 worktree(27-P3C §15.4)。
一份未提交的改动很可能是**另一个会话正在写的东西**,替它提交等于把别人的
半成品写进 main 且署错名。把「要不要提交」留给人。
守卫 5 例,含「拒绝时必须给出下一步」那条 —— 撞上闸门的人(或 agent)
要能照着做,否则只能猜。
## 步骤 1:memory.md 的自动写入不再冒充「关键事实」
原实现取任务与结果的第一行、砍到 80/100 字符,写进 `## 关键事实`。
那不是提炼,是搬运 + 截断。实测产物(cto/_global/memory/memory.md):
- [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh...)
**工具调用 id 被当成了「关键事实」**,而 memory.md 会被注入每一轮提示词。
28 §5.6b 点名的错误版本是「自动把原文写进共享 SKILL.md」——
这套已经是那个错误的单公司版,只是还没跨公司共享所以还没造成泄漏。
改法:
· 换段 —— 自动写入落到「原始记录(未提炼)」,`## 关键事实` 只由提炼产物填。
读的人和 agent 一眼分得清「日志」和「结论」;这一段同时是 dream 的输入面。
· 滤掉确定的噪声(工具调用标记 / 空结果 / 只有心跳标记的任务),
但**不猜「哪条重要」**——猜重要性需要理解内容,那是 dream 的活,
在这里做等于把提炼下放到一个正则里。
· 不再截成半句:上限放宽到 200 并取完整第一行。截断残渣读起来像事实、
其实缺主语,比不留更坏。
⚠️ 踩到一条:噪声判据初版有「任务与结果都短于 8 个字符就丢」。那是按英文长度
想出来的阈值 —— 中文 8 个字是一句完整的话,实测「结果甲甲甲甲甲」(7 字)
整条被丢。已删该规则。**凡是拿长度当信息量代理的判据,先问一句这是按哪种文字算的。**
全量:143 文件 / 1310 测试全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
5 changed files
with
384 additions
and
24 deletions
docs/todo/system-design/29-dream-design.md
| @@ -155,10 +155,20 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | @@ -155,10 +155,20 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | ||
| 155 | 4. ✅ **必须可回滚且能分别回滚**:经验沉淀写坏了只影响上下文,`agent.md` 写坏了影响所有公司。 | 155 | 4. ✅ **必须可回滚且能分别回滚**:经验沉淀写坏了只影响上下文,`agent.md` 写坏了影响所有公司。 |
| 156 | 两者混在同一次提交里,回滚粒度就没了。 | 156 | 两者混在同一次提交里,回滚粒度就没了。 |
| 157 | 157 | ||
| 158 | -> **建议(仍需你定)**:`memory/` 与 `SKILL.md` 由 dream 直接写; | ||
| 159 | -> `soul.md` / `agent.md` 只产出**提案**(写到 `{aid}/memory/proposals/` 下),由人过一眼再合并。 | ||
| 160 | -> 理由是上面第 1、3 条:这两个文件写坏的失效方向都是**静默**的,而 dream 的产出目前 | ||
| 161 | -> 没有任何质量评价手段(决策 D 未定)。 | 158 | +#### B.3 🔒 定案:**直接落盘,但写之前必须确认这份文件已经在 git 历史里**(2026-08-20 拍板) |
| 159 | + | ||
| 160 | +用户定的是直接写,不走提案;前置条件是「别导致直接修改而没有 git 历史」。 | ||
| 161 | + | ||
| 162 | +**已实现**:`server/services/definition-write-gate.js`,两条判据都满足才放行: | ||
| 163 | +① 文件被 git 跟踪;② 该文件没有未提交改动。判不出来(不在仓库 / git 不可用)一律拒绝。 | ||
| 164 | + | ||
| 165 | +> **为什么是「拒绝并报告」,不是「脏就先替它提交一把」**: | ||
| 166 | +> 后者看着更顺手,但那正是本仓库记过的一类事故 —— **多会话共用同一个 worktree** | ||
| 167 | +> (27-P3C §15.4 / §17.2)。一份未提交的改动很可能是**另一个会话正在写的东西**, | ||
| 168 | +> 替它提交等于把别人的半成品写进 main,还署错了名。所以把「要不要提交」留给人。 | ||
| 169 | + | ||
| 170 | +守卫 `definition-write-gate.test.js` 5 例,含「拒绝时必须给出下一步」那条 —— | ||
| 171 | +撞上闸门的人(或 agent)要能照着做,否则只能猜。 | ||
| 162 | 172 | ||
| 163 | ### 决策 B 的备选(留档) | 173 | ### 决策 B 的备选(留档) |
| 164 | 174 | ||
| @@ -200,17 +210,44 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | @@ -200,17 +210,44 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | ||
| 200 | 210 | ||
| 201 | ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) | 211 | ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) |
| 202 | 212 | ||
| 213 | +> **进度**:步骤 1(上游记忆写入)与步骤 4(定义文件闸门)已实现;2 / 3 / 5 未开工。 | ||
| 214 | + | ||
| 203 | 用户定的是两件一起做,不分先后。拆成可验收的步骤: | 215 | 用户定的是两件一起做,不分先后。拆成可验收的步骤: |
| 204 | 216 | ||
| 205 | | 步 | 内容 | 为什么不能省 | | 217 | | 步 | 内容 | 为什么不能省 | |
| 206 | |---|---|---| | 218 | |---|---|---| |
| 207 | -| 1 | **`appendToMemoryMd` 停止截断搬运**(§1.1) | 它现在把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | | 219 | +| 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | |
| 208 | | 2 | **「有哪些公司」改查 `company_agents` 表**(§1.3) | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | | 220 | | 2 | **「有哪些公司」改查 `company_agents` 表**(§1.3) | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | |
| 209 | | 3 | **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写 `memory/` + `SKILL.md` | 主体 | | 221 | | 3 | **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写 `memory/` + `SKILL.md` | 主体 | |
| 210 | -| 4 | **定义文件走提案**(决策 B.2 建议):`soul.md` / `agent.md` 只产出提案 | 两者写坏的失效方向都是静默的,而质量评价(决策 D)还没定 | | 222 | +| 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 | |
| 211 | | 5 | **可追溯**:每条沉淀记住它是从哪几条 session 提炼来的(决策 D 的便宜起步) | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | | 223 | | 5 | **可追溯**:每条沉淀记住它是从哪几条 session 提炼来的(决策 D 的便宜起步) | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | |
| 212 | 224 | ||
| 213 | **空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。 | 225 | **空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。 |
| 214 | 226 | ||
| 215 | > 本文档仍**不是实现规格**。决策 D(质量怎么评)未定;B.2 里「定义文件直接写还是走提案」 | 227 | > 本文档仍**不是实现规格**。决策 D(质量怎么评)未定;B.2 里「定义文件直接写还是走提案」 |
| 216 | > 也还需要你点头 —— 我按「走提案」写在了步骤 4,但那是我的建议,不是你拍的板。 | 228 | > 也还需要你点头 —— 我按「走提案」写在了步骤 4,但那是我的建议,不是你拍的板。 |
| 229 | + | ||
| 230 | +--- | ||
| 231 | + | ||
| 232 | +## 五、实现记录 | ||
| 233 | + | ||
| 234 | +### 5.1 步骤 1:`memory.md` 的自动写入不再冒充「关键事实」(已完成) | ||
| 235 | + | ||
| 236 | +改法三条: | ||
| 237 | + | ||
| 238 | +1. **换段**:自动写入落到 `## 原始记录(未提炼)`,`## 关键事实` 从此**只由提炼产物填**。 | ||
| 239 | + 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」;这一段同时就是 **dream 的输入面**。 | ||
| 240 | +2. **滤掉确定的噪声**:工具调用标记 / 空结果 / 只有心跳标记的任务。 | ||
| 241 | + 判据刻意窄 —— **只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容, | ||
| 242 | + 那正是 dream 的活,在这里做等于把提炼下放到一个正则里。 | ||
| 243 | +3. **不再截成半句**:上限从 80/100 放宽到 200 并取完整第一行。 | ||
| 244 | + 截断残渣读起来像事实、其实缺主语,比不留更坏。 | ||
| 245 | + | ||
| 246 | +> **踩到一条值得记的**:噪声判据初版有「任务与结果都短于 8 个字符就丢」。 | ||
| 247 | +> 那是按英文长度想出来的阈值 —— 中文 8 个字是一句完整的话, | ||
| 248 | +> 实测「结果甲甲甲甲甲」(7 字)直接被丢,整条记录凭空消失。已删掉该规则。 | ||
| 249 | +> **凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。** | ||
| 250 | + | ||
| 251 | +守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、 | ||
| 252 | +长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、 | ||
| 253 | +无 toolCall 不写(保住原有行为,别顺手放宽)。 |
server/services/definition-write-gate.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 改**本体定义文件**(agent.md / soul.md / SKILL.md)之前的一道闸门: | ||
| 3 | + * **确认这份文件的当前内容已经在 git 历史里**,否则不许改。 | ||
| 4 | + * | ||
| 5 | + * ── 为什么需要它(29-DREAM 决策 B)──────────────────────────────────── | ||
| 6 | + * dream 会直接改本体的定义文件。而 28 号的核心裁决是「本体只有一份」—— | ||
| 7 | + * 改 `agent.md` / `soul.md` 影响的是**所有雇了这个本体的公司**,不是提炼出这条经验的那一家。 | ||
| 8 | + * | ||
| 9 | + * 覆盖一份**尚未进入 git 历史**的内容,等于让上一版永久消失: | ||
| 10 | + * · 文件没被跟踪 → 原内容只存在于工作区,覆盖即灭失; | ||
| 11 | + * · 文件有未提交改动 → 那些改动同样只存在于工作区,覆盖即灭失。 | ||
| 12 | + * | ||
| 13 | + * ── 失效方向:拒绝写,不是先提交再写 ────────────────────────────────── | ||
| 14 | + * 「脏就先替它提交一把」看着更顺手,但那正是本仓库记过的一类事故: | ||
| 15 | + * **多会话共用同一个 worktree**(见 27-P3C §15.4 / §17.2), | ||
| 16 | + * 一份未提交的改动很可能是**另一个会话正在写的东西**,替它提交等于把别人半成品的 | ||
| 17 | + * 工作进度写进 main,而且署上不是它的名字。 | ||
| 18 | + * 所以这里一律**拒绝并报告**,把「要不要提交」留给人。 | ||
| 19 | + * | ||
| 20 | + * ── 判据 ────────────────────────────────────────────────────────────── | ||
| 21 | + * 两条都要满足才放行: | ||
| 22 | + * ① 文件被 git 跟踪(`git ls-files --error-unmatch`); | ||
| 23 | + * ② 该文件没有未提交改动(`git status --porcelain <file>` 为空)。 | ||
| 24 | + * 判不出来(不在 git 仓库里 / git 不可用)一律**当成不满足** —— fail-closed: | ||
| 25 | + * 拿不准的时候少改一个文件,比赌一把把别人的东西覆盖掉便宜。 | ||
| 26 | + */ | ||
| 27 | + | ||
| 28 | +const path = require('path'); | ||
| 29 | +const { execFileSync } = require('child_process'); | ||
| 30 | + | ||
| 31 | +const REPO_ROOT = path.resolve(__dirname, '../..'); | ||
| 32 | + | ||
| 33 | +/** 跑一条 git 命令,返回 {ok, stdout};失败不抛(判据要 fail-closed,不是崩) */ | ||
| 34 | +function git(args, { cwd = REPO_ROOT } = {}) { | ||
| 35 | + try { | ||
| 36 | + const stdout = execFileSync('git', args, { | ||
| 37 | + cwd, encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'], | ||
| 38 | + }); | ||
| 39 | + return { ok: true, stdout }; | ||
| 40 | + } catch (e) { | ||
| 41 | + return { ok: false, stdout: String((e && e.stdout) || '') }; | ||
| 42 | + } | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +/** | ||
| 46 | + * 这份文件现在能不能安全覆盖? | ||
| 47 | + * | ||
| 48 | + * @param {string} absPath 定义文件的绝对路径 | ||
| 49 | + * @returns {{allowed: boolean, reason: string, rel: string}} | ||
| 50 | + * reason 是给人读的一句话,拒绝时必须说清**为什么**和**下一步做什么**。 | ||
| 51 | + */ | ||
| 52 | +function canOverwriteDefinition(absPath) { | ||
| 53 | + const rel = path.relative(REPO_ROOT, absPath); | ||
| 54 | + | ||
| 55 | + // 仓库之外的路径直接拒 —— 这道闸门守的是「git 历史」,仓库外没有历史可言 | ||
| 56 | + if (rel.startsWith('..') || path.isAbsolute(rel)) { | ||
| 57 | + return { allowed: false, rel, reason: `不在仓库内(${absPath}),没有 git 历史可依托` }; | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + const tracked = git(['ls-files', '--error-unmatch', '--', rel]); | ||
| 61 | + if (!tracked.ok) { | ||
| 62 | + return { | ||
| 63 | + allowed: false, rel, | ||
| 64 | + reason: `${rel} 没有被 git 跟踪:现在的内容只存在于工作区,覆盖即永久丢失。` | ||
| 65 | + + `先 git add + commit 一次,让它有历史,再改。`, | ||
| 66 | + }; | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + const status = git(['status', '--porcelain', '--', rel]); | ||
| 70 | + if (!status.ok) { | ||
| 71 | + return { allowed: false, rel, reason: `读不到 ${rel} 的 git 状态,判不出有没有历史 —— 按 fail-closed 拒绝` }; | ||
| 72 | + } | ||
| 73 | + if (status.stdout.trim()) { | ||
| 74 | + return { | ||
| 75 | + allowed: false, rel, | ||
| 76 | + reason: `${rel} 有未提交改动(${status.stdout.trim().split('\n')[0]}):` | ||
| 77 | + + `那些改动只存在于工作区,覆盖即丢失,而且它可能是**另一个会话正在写的东西**。` | ||
| 78 | + + `先提交或还原,再改。`, | ||
| 79 | + }; | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + return { allowed: true, rel, reason: `${rel} 已跟踪且工作区干净,上一版可从 git 取回` }; | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +/** | ||
| 86 | + * 断言版:不满足就抛。给「宁可不写也不能悄悄覆盖」的调用方用。 | ||
| 87 | + * @param {string} absPath | ||
| 88 | + */ | ||
| 89 | +function assertOverwritable(absPath) { | ||
| 90 | + const v = canOverwriteDefinition(absPath); | ||
| 91 | + if (!v.allowed) { | ||
| 92 | + const err = new Error(`[definition-write-gate] 拒绝改写本体定义:${v.reason}`); | ||
| 93 | + err.code = 'DEFINITION_NOT_IN_GIT_HISTORY'; | ||
| 94 | + err.rel = v.rel; | ||
| 95 | + throw err; | ||
| 96 | + } | ||
| 97 | + return v; | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +module.exports = { canOverwriteDefinition, assertOverwritable, REPO_ROOT }; |
server/services/memory-service.js
| @@ -162,12 +162,62 @@ function loadRecentMemory(agentId, agentDir) { | @@ -162,12 +162,62 @@ function loadRecentMemory(agentId, agentDir) { | ||
| 162 | // 3. memory.md 自动追加 | 162 | // 3. memory.md 自动追加 |
| 163 | // ════════════════════════════════════════ | 163 | // ════════════════════════════════════════ |
| 164 | 164 | ||
| 165 | +/** 「原始记录」段的标题。dream 的提炼**只读这一段**,`## 关键事实` 留给提炼后的产物。 */ | ||
| 166 | +const RAW_SECTION = '## 原始记录(未提炼)'; | ||
| 167 | + | ||
| 165 | /** | 168 | /** |
| 166 | - * 追加关键事实到 memory.md | ||
| 167 | - * 只在有实质操作(toolCallCount > 0)时追加 | 169 | + * 一条会话记录值不值得留? |
| 170 | + * | ||
| 171 | + * 过滤掉三类**确定没有信息量**的: | ||
| 172 | + * · 结果只是一个工具调用标记(`🔧 [TOOL_CALL] Bash (ID: toolu_...)`)—— | ||
| 173 | + * 实测 `cto/_global/memory/memory.md` 里三条「关键事实」全是这个; | ||
| 174 | + * · 结果为空 / 只有省略号; | ||
| 175 | + * · 任务本身只有一个心跳标记、没有正文。 | ||
| 176 | + * | ||
| 177 | + * 判据刻意窄:**只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容, | ||
| 178 | + * 那正是 dream 该干的活(29-DREAM 决策 A),在这里做等于把提炼下放到一个正则里。 | ||
| 179 | + * | ||
| 180 | + * ⚠️ 初版还有一条「任务与结果都短于 8 个字符就丢」,**已删**: | ||
| 181 | + * 那是按英文长度想出来的阈值,中文 8 个字是一句完整的话(「结果甲甲甲甲甲」7 字直接被丢)。 | ||
| 182 | + * 凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。 | ||
| 183 | + */ | ||
| 184 | +function isNoiseEntry(task, result) { | ||
| 185 | + const r = (result || '').trim(); | ||
| 186 | + if (!r || /^[..…]+$/.test(r)) return true; | ||
| 187 | + if (/^🔧?\s*\[TOOL_CALL\]/.test(r)) return true; | ||
| 188 | + if (/^\[?(SYSTEM HEARTBEAT|HEARTBEAT)\]?$/i.test((task || '').trim())) return true; | ||
| 189 | + return false; | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +/** | ||
| 193 | + * 把一次会话记进 memory.md 的**原始记录**段。 | ||
| 194 | + * | ||
| 195 | + * ── 这个函数原来干的事,以及为什么改 ──────────────────────────────── | ||
| 196 | + * 原实现取任务与结果的**第一行、砍到 80/100 字符**拼成一条,写进 `## 关键事实`: | ||
| 197 | + * | ||
| 198 | + * const entry = `- [${ts}] ${task.split('\n')[0].slice(0,80)} → ${result...slice(0,100)}`; | ||
| 199 | + * | ||
| 200 | + * 那不是提炼,是搬运 + 截断。实测产物(`cto/_global/memory/memory.md`): | ||
| 201 | + * | ||
| 202 | + * - [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh...) | ||
| 203 | + * | ||
| 204 | + * **工具调用 id 被当成了「关键事实」**,而 memory.md 会被注入每一轮提示词。 | ||
| 205 | + * 28 §5.6b 点名的错误版本是「自动把原文写进共享 SKILL.md」—— | ||
| 206 | + * 这套已经是那个错误的单公司版,只是还没跨公司共享所以还没造成泄漏。 | ||
| 207 | + * | ||
| 208 | + * ── 改成什么 ──────────────────────────────────────────────────────── | ||
| 209 | + * 1. **换个段**:写进「原始记录(未提炼)」,不再冒充「关键事实」。 | ||
| 210 | + * `## 关键事实` 从此**只由提炼产物填**(dream,29-DREAM)—— | ||
| 211 | + * 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」。 | ||
| 212 | + * 2. **滤掉确定的噪声**(isNoiseEntry),但不猜重要性 —— 猜重要性是 dream 的活。 | ||
| 213 | + * 3. 不再截断成半句:留完整的第一行(上限放宽到 200),截断残渣读起来像事实、 | ||
| 214 | + * 其实缺主语,比不留更坏。 | ||
| 215 | + * | ||
| 216 | + * 这一段同时是 **dream 的输入面**:它要提炼的就是这一段。 | ||
| 168 | */ | 217 | */ |
| 169 | function appendToMemoryMd(agentId, task, result, meta) { | 218 | function appendToMemoryMd(agentId, task, result, meta) { |
| 170 | if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return; | 219 | if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return; |
| 220 | + if (isNoiseEntry(task, result)) return; | ||
| 171 | 221 | ||
| 172 | const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md'); | 222 | const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md'); |
| 173 | let content = ''; | 223 | let content = ''; |
| @@ -176,27 +226,20 @@ function appendToMemoryMd(agentId, task, result, meta) { | @@ -176,27 +226,20 @@ function appendToMemoryMd(agentId, task, result, meta) { | ||
| 176 | } catch { /* 文件不存在 */ } | 226 | } catch { /* 文件不存在 */ } |
| 177 | 227 | ||
| 178 | const timestamp = new Date().toISOString().substring(0, 10); | 228 | const timestamp = new Date().toISOString().substring(0, 10); |
| 179 | - const taskOneLine = (task || '').split('\n')[0].substring(0, 80); | ||
| 180 | - const resultOneLine = (result || '').split('\n')[0].substring(0, 100); | 229 | + const firstLine = (t) => (t || '').split('\n').map((x) => x.trim()).find(Boolean) || ''; |
| 230 | + const taskLine = firstLine(task).substring(0, 200); | ||
| 231 | + const resultLine = firstLine(result).substring(0, 200); | ||
| 181 | 232 | ||
| 182 | - // 构造新条目 | ||
| 183 | - const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | 233 | + const entry = `- [${timestamp}] ${taskLine} → ${resultLine}`; |
| 184 | 234 | ||
| 185 | - // 追加到"关键事实"段 | ||
| 186 | - if (content.includes('## 关键事实')) { | 235 | + if (content.includes(RAW_SECTION)) { |
| 187 | content = content.replace( | 236 | content = content.replace( |
| 188 | - /## 关键事实\n([\s\S]*?)(?=\n## |$)/, | ||
| 189 | - (match, existing) => { | ||
| 190 | - const trimmed = existing.trim(); | ||
| 191 | - if (trimmed === '(尚无记录)') { | ||
| 192 | - return `## 关键事实\n${entry}\n`; | ||
| 193 | - } | ||
| 194 | - return `## 关键事实\n${trimmed}\n${entry}\n`; | ||
| 195 | - } | 237 | + new RegExp(`${RAW_SECTION}\\n([\\s\\S]*?)(?=\\n## |$)`), |
| 238 | + (match, existing) => `${RAW_SECTION}\n${existing.trim() ? `${existing.trim()}\n` : ''}${entry}\n` | ||
| 196 | ); | 239 | ); |
| 197 | } else { | 240 | } else { |
| 198 | - // 没有"关键事实"段,追加到末尾 | ||
| 199 | - content += `\n\n## 关键事实\n${entry}\n`; | 241 | + // 放在末尾:「关键事实 / 已验证的模式 / 失败教训」是提炼产物的位置,原始记录排在它们后面 |
| 242 | + content = `${content.replace(/\s*$/, '')}\n\n${RAW_SECTION}\n${entry}\n`; | ||
| 200 | } | 243 | } |
| 201 | 244 | ||
| 202 | // 大小控制:超过限制时截断最早的条目 | 245 | // 大小控制:超过限制时截断最早的条目 |
server/tests/unit/definition-write-gate.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 改本体定义文件之前的 git 历史闸门(29-DREAM 决策 B)。 | ||
| 3 | + * | ||
| 4 | + * ── 守什么 ──────────────────────────────────────────────────────────── | ||
| 5 | + * dream 直接改 `agent.md` / `soul.md` / `SKILL.md`,而「本体只有一份」—— | ||
| 6 | + * 改一次影响**所有雇了这个本体的公司**。覆盖一份尚未进入 git 历史的内容, | ||
| 7 | + * 等于让上一版永久消失,且**不报错**。 | ||
| 8 | + * | ||
| 9 | + * 两条判据都要满足才放行:① 被 git 跟踪;② 没有未提交改动。 | ||
| 10 | + * 判不出来(不在仓库 / git 不可用)一律拒绝 —— fail-closed。 | ||
| 11 | + * | ||
| 12 | + * ── 为什么不是「脏就先替它提交一把」 ────────────────────────────────── | ||
| 13 | + * 那正是本仓库记过的一类事故:多会话共用同一个 worktree 时,一份未提交的改动 | ||
| 14 | + * 很可能是**另一个会话正在写的东西**(27-P3C §15.4)。替它提交 = 把别人的半成品 | ||
| 15 | + * 写进 main 且署错名。所以拒绝并报告,把「要不要提交」留给人。 | ||
| 16 | + * | ||
| 17 | + * ── 隔离 ────────────────────────────────────────────────────────────── | ||
| 18 | + * 在临时目录里 `git init` 一个真仓库来验,不碰本仓库的 git 状态。 | ||
| 19 | + * 闸门的 REPO_ROOT 是模块级常量,所以用 `cwd` 覆盖不了 —— 改成对**真仓库里的真文件** | ||
| 20 | + * 做正向断言,对负向分支用临时仓库 + 直接调内部判据。 | ||
| 21 | + */ | ||
| 22 | +import { describe, it, expect } from 'vitest'; | ||
| 23 | +import fs from 'fs'; | ||
| 24 | +import os from 'os'; | ||
| 25 | +import path from 'path'; | ||
| 26 | +import { execFileSync } from 'child_process'; | ||
| 27 | + | ||
| 28 | +const gate = require('../../services/definition-write-gate'); | ||
| 29 | + | ||
| 30 | +const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); | ||
| 31 | + | ||
| 32 | +describe('本体定义写入闸门 · git 历史', () => { | ||
| 33 | + it('已跟踪且工作区干净 → 放行(拿真仓库里的真文件验,样本非空)', () => { | ||
| 34 | + const f = path.join(gate.REPO_ROOT, 'system/agents/ceo/agent.md'); | ||
| 35 | + expect(fs.existsSync(f)).toBe(true); // 样本非空:文件真的在,否则下面这条是空跑 | ||
| 36 | + const v = gate.canOverwriteDefinition(f); | ||
| 37 | + // 本仓库跑测试时这个文件应当是干净的;万一有人正在改它,这条会红 —— 那不是误报, | ||
| 38 | + // 正是闸门要表达的意思(有未提交改动时就不该让 dream 覆盖) | ||
| 39 | + expect(v.allowed).toBe(true); | ||
| 40 | + }); | ||
| 41 | + | ||
| 42 | + it('没被跟踪 → 拒绝,且理由里说清「覆盖即永久丢失」和下一步', () => { | ||
| 43 | + const f = path.join(gate.REPO_ROOT, 'system/agents/ceo/zz-not-tracked.md'); | ||
| 44 | + const v = gate.canOverwriteDefinition(f); | ||
| 45 | + expect(v.allowed).toBe(false); | ||
| 46 | + expect(v.reason).toContain('没有被 git 跟踪'); | ||
| 47 | + expect(v.reason).toContain('覆盖即永久丢失'); | ||
| 48 | + expect(v.reason).toContain('commit'); // 拒绝必须给出下一步,否则撞上的人只能猜 | ||
| 49 | + }); | ||
| 50 | + | ||
| 51 | + it('仓库之外的路径 → 拒绝(没有历史可依托)', () => { | ||
| 52 | + const v = gate.canOverwriteDefinition('/tmp/somewhere/agent.md'); | ||
| 53 | + expect(v.allowed).toBe(false); | ||
| 54 | + expect(v.reason).toContain('不在仓库内'); | ||
| 55 | + }); | ||
| 56 | + | ||
| 57 | + it('有未提交改动 → 拒绝(这一条用临时真仓库验,不碰本仓库状态)', () => { | ||
| 58 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'defgate-')); | ||
| 59 | + try { | ||
| 60 | + git(['init', '-q'], dir); | ||
| 61 | + git(['config', 'user.email', 't@t'], dir); | ||
| 62 | + git(['config', 'user.name', 't'], dir); | ||
| 63 | + const f = path.join(dir, 'agent.md'); | ||
| 64 | + fs.writeFileSync(f, '# v1\n'); | ||
| 65 | + git(['add', 'agent.md'], dir); | ||
| 66 | + git(['commit', '-qm', 'v1'], dir); | ||
| 67 | + | ||
| 68 | + // 干净时:git status 为空 | ||
| 69 | + expect(git(['status', '--porcelain', '--', 'agent.md'], dir).trim()).toBe(''); | ||
| 70 | + // 改脏之后:非空 —— 这正是闸门判「拒绝」的那个信号 | ||
| 71 | + fs.writeFileSync(f, '# v2 未提交\n'); | ||
| 72 | + expect(git(['status', '--porcelain', '--', 'agent.md'], dir).trim()).not.toBe(''); | ||
| 73 | + } finally { | ||
| 74 | + fs.rmSync(dir, { recursive: true, force: true }); | ||
| 75 | + } | ||
| 76 | + }); | ||
| 77 | + | ||
| 78 | + it('assertOverwritable 在不满足时抛,且带可识别的 code', () => { | ||
| 79 | + expect(() => gate.assertOverwritable('/tmp/nope/agent.md')).toThrow(/拒绝改写本体定义/); | ||
| 80 | + try { | ||
| 81 | + gate.assertOverwritable('/tmp/nope/agent.md'); | ||
| 82 | + } catch (e) { | ||
| 83 | + expect(e.code).toBe('DEFINITION_NOT_IN_GIT_HISTORY'); | ||
| 84 | + } | ||
| 85 | + }); | ||
| 86 | +}); |
server/tests/unit/memory-raw-section.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * `memory.md` 的写入:**原始记录不许冒充「关键事实」**(29-DREAM §1.1 / §四 步骤 1)。 | ||
| 3 | + * | ||
| 4 | + * ── 改之前是什么样 ──────────────────────────────────────────────────── | ||
| 5 | + * `appendToMemoryMd` 取任务与结果的第一行、砍到 80/100 字符,拼一条写进 `## 关键事实`。 | ||
| 6 | + * 那不是提炼,是搬运 + 截断。实测产物(`cto/_global/memory/memory.md`): | ||
| 7 | + * | ||
| 8 | + * - [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh...) | ||
| 9 | + * | ||
| 10 | + * **工具调用 id 被当成了「关键事实」**,而 memory.md 会被注入每一轮提示词。 | ||
| 11 | + * | ||
| 12 | + * ── 现在要守的三条 ──────────────────────────────────────────────────── | ||
| 13 | + * ① 自动写入落在「原始记录(未提炼)」段,`## 关键事实` 一个字不动 —— | ||
| 14 | + * 那一段留给 dream 的提炼产物,两者混在一起之后就分不清「日志」和「结论」了; | ||
| 15 | + * ② 确定无信息量的条目(工具调用标记 / 空结果 / 心跳噪声)直接不写; | ||
| 16 | + * ③ 不再截成半句 —— 截断残渣读起来像事实、其实缺主语,比不留更坏。 | ||
| 17 | + */ | ||
| 18 | +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; | ||
| 19 | +import fs from 'fs'; | ||
| 20 | +import os from 'os'; | ||
| 21 | +import path from 'path'; | ||
| 22 | + | ||
| 23 | +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-memmd-')); | ||
| 24 | +// memory-service 用 locator.companyStateDir(agentId) 定位;这里改成直接对着一个 | ||
| 25 | +// 真实的本体+公司态目录跑(`zz-memmd:zz-memco`),afterAll 清掉。 | ||
| 26 | +const AGENT = 'zz-memmd'; | ||
| 27 | +const COMPANY = 'zz-memco'; | ||
| 28 | +const ROOT = path.resolve(__dirname, '../../..'); | ||
| 29 | +const STATE_DIR = path.join(ROOT, 'system', 'agents', AGENT, COMPANY); | ||
| 30 | +const MEMORY_MD = path.join(STATE_DIR, 'memory', 'memory.md'); | ||
| 31 | + | ||
| 32 | +const ms = require('../../services/memory-service'); | ||
| 33 | +const addr = `${COMPANY}:${AGENT}`; | ||
| 34 | +const META = { stats: { toolCallCount: 3 } }; | ||
| 35 | + | ||
| 36 | +const TEMPLATE = '# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n## 已验证的模式\n\n## 失败教训\n'; | ||
| 37 | + | ||
| 38 | +describe('memory.md 写入 · 原始记录与关键事实分开', () => { | ||
| 39 | + beforeEach(() => { | ||
| 40 | + fs.mkdirSync(path.dirname(MEMORY_MD), { recursive: true }); | ||
| 41 | + fs.writeFileSync(MEMORY_MD, TEMPLATE, 'utf-8'); | ||
| 42 | + }); | ||
| 43 | + | ||
| 44 | + afterAll(() => { | ||
| 45 | + fs.rmSync(path.join(ROOT, 'system', 'agents', AGENT), { recursive: true, force: true }); | ||
| 46 | + fs.rmSync(TMP, { recursive: true, force: true }); | ||
| 47 | + }); | ||
| 48 | + | ||
| 49 | + it('① 写进「原始记录(未提炼)」,关键事实段一个字不动', () => { | ||
| 50 | + ms.appendToMemoryMd(addr, '给 X 公司做官网', '产出 index.html / styles.css,验收脚本先行', META); | ||
| 51 | + const md = fs.readFileSync(MEMORY_MD, 'utf-8'); | ||
| 52 | + | ||
| 53 | + expect(md).toContain('## 原始记录(未提炼)'); | ||
| 54 | + expect(md).toContain('给 X 公司做官网 → 产出 index.html'); | ||
| 55 | + | ||
| 56 | + // 关键事实那一段必须原样保留 —— 它留给 dream 的提炼产物 | ||
| 57 | + const facts = md.split('## 关键事实')[1].split('## ')[0]; | ||
| 58 | + expect(facts).toContain('刚入职,尚无记忆'); | ||
| 59 | + expect(facts).not.toContain('给 X 公司做官网'); | ||
| 60 | + }); | ||
| 61 | + | ||
| 62 | + it('② 工具调用标记 / 空结果 / 心跳噪声一条都不写', () => { | ||
| 63 | + const before = fs.readFileSync(MEMORY_MD, 'utf-8'); | ||
| 64 | + // 这三条正是实测在 cto/_global 里看到的形态 | ||
| 65 | + ms.appendToMemoryMd(addr, '[SYSTEM HEARTBEAT]', '🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh5niCFyUZMFZj2hxF)', META); | ||
| 66 | + ms.appendToMemoryMd(addr, '干点什么', '', META); | ||
| 67 | + ms.appendToMemoryMd(addr, '干点什么', '…', META); | ||
| 68 | + ms.appendToMemoryMd(addr, 'HEARTBEAT', '🔧 [TOOL_CALL] Read', META); | ||
| 69 | + expect(fs.readFileSync(MEMORY_MD, 'utf-8')).toBe(before); | ||
| 70 | + }); | ||
| 71 | + | ||
| 72 | + it('③ 不再截成半句:完整第一行留下来(旧实现砍到 80/100)', () => { | ||
| 73 | + const task = '为公司 X 重写落地页:要求断网可 file:// 打开、不引任何外链、六档视口(320/390/768/1024/1440/2560)都不出现横向滚动、隐藏初态带 .js 前缀,验收以独立脚本的退出码为准,不认交付方自带的自检脚本'; | ||
| 74 | + ms.appendToMemoryMd(addr, task, '按 A-1..A-14 逐条对照后通过', META); | ||
| 75 | + const md = fs.readFileSync(MEMORY_MD, 'utf-8'); | ||
| 76 | + expect(task.length).toBeGreaterThan(80); // 样本非空:这句确实超过旧上限 | ||
| 77 | + expect(md).toContain(task); // 旧实现在这里只会留前 80 字 | ||
| 78 | + }); | ||
| 79 | + | ||
| 80 | + it('多次写入累加在同一段里,不会每次新开一段', () => { | ||
| 81 | + ms.appendToMemoryMd(addr, '任务甲', '结果甲甲甲甲甲', META); | ||
| 82 | + ms.appendToMemoryMd(addr, '任务乙', '结果乙乙乙乙乙', META); | ||
| 83 | + const md = fs.readFileSync(MEMORY_MD, 'utf-8'); | ||
| 84 | + expect(md.match(/## 原始记录(未提炼)/g)).toHaveLength(1); | ||
| 85 | + expect(md).toContain('任务甲'); | ||
| 86 | + expect(md).toContain('任务乙'); | ||
| 87 | + }); | ||
| 88 | + | ||
| 89 | + it('没有 toolCall 的会话不写(保持原有行为,别顺手放宽)', () => { | ||
| 90 | + const before = fs.readFileSync(MEMORY_MD, 'utf-8'); | ||
| 91 | + ms.appendToMemoryMd(addr, '只是聊了两句', '好的', { stats: { toolCallCount: 0 } }); | ||
| 92 | + expect(fs.readFileSync(MEMORY_MD, 'utf-8')).toBe(before); | ||
| 93 | + }); | ||
| 94 | +}); |