diff --git a/docs/todo/system-design/29-dream-design.md b/docs/todo/system-design/29-dream-design.md index f05c64f..5cf5bea 100644 --- a/docs/todo/system-design/29-dream-design.md +++ b/docs/todo/system-design/29-dream-design.md @@ -155,10 +155,20 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; 4. ✅ **必须可回滚且能分别回滚**:经验沉淀写坏了只影响上下文,`agent.md` 写坏了影响所有公司。 两者混在同一次提交里,回滚粒度就没了。 -> **建议(仍需你定)**:`memory/` 与 `SKILL.md` 由 dream 直接写; -> `soul.md` / `agent.md` 只产出**提案**(写到 `{aid}/memory/proposals/` 下),由人过一眼再合并。 -> 理由是上面第 1、3 条:这两个文件写坏的失效方向都是**静默**的,而 dream 的产出目前 -> 没有任何质量评价手段(决策 D 未定)。 +#### B.3 🔒 定案:**直接落盘,但写之前必须确认这份文件已经在 git 历史里**(2026-08-20 拍板) + +用户定的是直接写,不走提案;前置条件是「别导致直接修改而没有 git 历史」。 + +**已实现**:`server/services/definition-write-gate.js`,两条判据都满足才放行: +① 文件被 git 跟踪;② 该文件没有未提交改动。判不出来(不在仓库 / git 不可用)一律拒绝。 + +> **为什么是「拒绝并报告」,不是「脏就先替它提交一把」**: +> 后者看着更顺手,但那正是本仓库记过的一类事故 —— **多会话共用同一个 worktree** +> (27-P3C §15.4 / §17.2)。一份未提交的改动很可能是**另一个会话正在写的东西**, +> 替它提交等于把别人的半成品写进 main,还署错了名。所以把「要不要提交」留给人。 + +守卫 `definition-write-gate.test.js` 5 例,含「拒绝时必须给出下一步」那条 —— +撞上闸门的人(或 agent)要能照着做,否则只能猜。 ### 决策 B 的备选(留档) @@ -200,17 +210,44 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) +> **进度**:步骤 1(上游记忆写入)与步骤 4(定义文件闸门)已实现;2 / 3 / 5 未开工。 + 用户定的是两件一起做,不分先后。拆成可验收的步骤: | 步 | 内容 | 为什么不能省 | |---|---|---| -| 1 | **`appendToMemoryMd` 停止截断搬运**(§1.1) | 它现在把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | +| 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | | 2 | **「有哪些公司」改查 `company_agents` 表**(§1.3) | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | | 3 | **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写 `memory/` + `SKILL.md` | 主体 | -| 4 | **定义文件走提案**(决策 B.2 建议):`soul.md` / `agent.md` 只产出提案 | 两者写坏的失效方向都是静默的,而质量评价(决策 D)还没定 | +| 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 | | 5 | **可追溯**:每条沉淀记住它是从哪几条 session 提炼来的(决策 D 的便宜起步) | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | **空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。 > 本文档仍**不是实现规格**。决策 D(质量怎么评)未定;B.2 里「定义文件直接写还是走提案」 > 也还需要你点头 —— 我按「走提案」写在了步骤 4,但那是我的建议,不是你拍的板。 + +--- + +## 五、实现记录 + +### 5.1 步骤 1:`memory.md` 的自动写入不再冒充「关键事实」(已完成) + +改法三条: + +1. **换段**:自动写入落到 `## 原始记录(未提炼)`,`## 关键事实` 从此**只由提炼产物填**。 + 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」;这一段同时就是 **dream 的输入面**。 +2. **滤掉确定的噪声**:工具调用标记 / 空结果 / 只有心跳标记的任务。 + 判据刻意窄 —— **只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容, + 那正是 dream 的活,在这里做等于把提炼下放到一个正则里。 +3. **不再截成半句**:上限从 80/100 放宽到 200 并取完整第一行。 + 截断残渣读起来像事实、其实缺主语,比不留更坏。 + +> **踩到一条值得记的**:噪声判据初版有「任务与结果都短于 8 个字符就丢」。 +> 那是按英文长度想出来的阈值 —— 中文 8 个字是一句完整的话, +> 实测「结果甲甲甲甲甲」(7 字)直接被丢,整条记录凭空消失。已删掉该规则。 +> **凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。** + +守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、 +长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、 +无 toolCall 不写(保住原有行为,别顺手放宽)。 diff --git a/server/services/definition-write-gate.js b/server/services/definition-write-gate.js new file mode 100644 index 0000000..d65c8c0 --- /dev/null +++ b/server/services/definition-write-gate.js @@ -0,0 +1,100 @@ +/** + * 改**本体定义文件**(agent.md / soul.md / SKILL.md)之前的一道闸门: + * **确认这份文件的当前内容已经在 git 历史里**,否则不许改。 + * + * ── 为什么需要它(29-DREAM 决策 B)──────────────────────────────────── + * dream 会直接改本体的定义文件。而 28 号的核心裁决是「本体只有一份」—— + * 改 `agent.md` / `soul.md` 影响的是**所有雇了这个本体的公司**,不是提炼出这条经验的那一家。 + * + * 覆盖一份**尚未进入 git 历史**的内容,等于让上一版永久消失: + * · 文件没被跟踪 → 原内容只存在于工作区,覆盖即灭失; + * · 文件有未提交改动 → 那些改动同样只存在于工作区,覆盖即灭失。 + * + * ── 失效方向:拒绝写,不是先提交再写 ────────────────────────────────── + * 「脏就先替它提交一把」看着更顺手,但那正是本仓库记过的一类事故: + * **多会话共用同一个 worktree**(见 27-P3C §15.4 / §17.2), + * 一份未提交的改动很可能是**另一个会话正在写的东西**,替它提交等于把别人半成品的 + * 工作进度写进 main,而且署上不是它的名字。 + * 所以这里一律**拒绝并报告**,把「要不要提交」留给人。 + * + * ── 判据 ────────────────────────────────────────────────────────────── + * 两条都要满足才放行: + * ① 文件被 git 跟踪(`git ls-files --error-unmatch`); + * ② 该文件没有未提交改动(`git status --porcelain ` 为空)。 + * 判不出来(不在 git 仓库里 / git 不可用)一律**当成不满足** —— fail-closed: + * 拿不准的时候少改一个文件,比赌一把把别人的东西覆盖掉便宜。 + */ + +const path = require('path'); +const { execFileSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '../..'); + +/** 跑一条 git 命令,返回 {ok, stdout};失败不抛(判据要 fail-closed,不是崩) */ +function git(args, { cwd = REPO_ROOT } = {}) { + try { + const stdout = execFileSync('git', args, { + cwd, encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'], + }); + return { ok: true, stdout }; + } catch (e) { + return { ok: false, stdout: String((e && e.stdout) || '') }; + } +} + +/** + * 这份文件现在能不能安全覆盖? + * + * @param {string} absPath 定义文件的绝对路径 + * @returns {{allowed: boolean, reason: string, rel: string}} + * reason 是给人读的一句话,拒绝时必须说清**为什么**和**下一步做什么**。 + */ +function canOverwriteDefinition(absPath) { + const rel = path.relative(REPO_ROOT, absPath); + + // 仓库之外的路径直接拒 —— 这道闸门守的是「git 历史」,仓库外没有历史可言 + if (rel.startsWith('..') || path.isAbsolute(rel)) { + return { allowed: false, rel, reason: `不在仓库内(${absPath}),没有 git 历史可依托` }; + } + + const tracked = git(['ls-files', '--error-unmatch', '--', rel]); + if (!tracked.ok) { + return { + allowed: false, rel, + reason: `${rel} 没有被 git 跟踪:现在的内容只存在于工作区,覆盖即永久丢失。` + + `先 git add + commit 一次,让它有历史,再改。`, + }; + } + + const status = git(['status', '--porcelain', '--', rel]); + if (!status.ok) { + return { allowed: false, rel, reason: `读不到 ${rel} 的 git 状态,判不出有没有历史 —— 按 fail-closed 拒绝` }; + } + if (status.stdout.trim()) { + return { + allowed: false, rel, + reason: `${rel} 有未提交改动(${status.stdout.trim().split('\n')[0]}):` + + `那些改动只存在于工作区,覆盖即丢失,而且它可能是**另一个会话正在写的东西**。` + + `先提交或还原,再改。`, + }; + } + + return { allowed: true, rel, reason: `${rel} 已跟踪且工作区干净,上一版可从 git 取回` }; +} + +/** + * 断言版:不满足就抛。给「宁可不写也不能悄悄覆盖」的调用方用。 + * @param {string} absPath + */ +function assertOverwritable(absPath) { + const v = canOverwriteDefinition(absPath); + if (!v.allowed) { + const err = new Error(`[definition-write-gate] 拒绝改写本体定义:${v.reason}`); + err.code = 'DEFINITION_NOT_IN_GIT_HISTORY'; + err.rel = v.rel; + throw err; + } + return v; +} + +module.exports = { canOverwriteDefinition, assertOverwritable, REPO_ROOT }; diff --git a/server/services/memory-service.js b/server/services/memory-service.js index a8440d1..3a216a7 100644 --- a/server/services/memory-service.js +++ b/server/services/memory-service.js @@ -162,12 +162,62 @@ function loadRecentMemory(agentId, agentDir) { // 3. memory.md 自动追加 // ════════════════════════════════════════ +/** 「原始记录」段的标题。dream 的提炼**只读这一段**,`## 关键事实` 留给提炼后的产物。 */ +const RAW_SECTION = '## 原始记录(未提炼)'; + /** - * 追加关键事实到 memory.md - * 只在有实质操作(toolCallCount > 0)时追加 + * 一条会话记录值不值得留? + * + * 过滤掉三类**确定没有信息量**的: + * · 结果只是一个工具调用标记(`🔧 [TOOL_CALL] Bash (ID: toolu_...)`)—— + * 实测 `cto/_global/memory/memory.md` 里三条「关键事实」全是这个; + * · 结果为空 / 只有省略号; + * · 任务本身只有一个心跳标记、没有正文。 + * + * 判据刻意窄:**只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容, + * 那正是 dream 该干的活(29-DREAM 决策 A),在这里做等于把提炼下放到一个正则里。 + * + * ⚠️ 初版还有一条「任务与结果都短于 8 个字符就丢」,**已删**: + * 那是按英文长度想出来的阈值,中文 8 个字是一句完整的话(「结果甲甲甲甲甲」7 字直接被丢)。 + * 凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。 + */ +function isNoiseEntry(task, result) { + const r = (result || '').trim(); + if (!r || /^[..…]+$/.test(r)) return true; + if (/^🔧?\s*\[TOOL_CALL\]/.test(r)) return true; + if (/^\[?(SYSTEM HEARTBEAT|HEARTBEAT)\]?$/i.test((task || '').trim())) return true; + return false; +} + +/** + * 把一次会话记进 memory.md 的**原始记录**段。 + * + * ── 这个函数原来干的事,以及为什么改 ──────────────────────────────── + * 原实现取任务与结果的**第一行、砍到 80/100 字符**拼成一条,写进 `## 关键事实`: + * + * const entry = `- [${ts}] ${task.split('\n')[0].slice(0,80)} → ${result...slice(0,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」—— + * 这套已经是那个错误的单公司版,只是还没跨公司共享所以还没造成泄漏。 + * + * ── 改成什么 ──────────────────────────────────────────────────────── + * 1. **换个段**:写进「原始记录(未提炼)」,不再冒充「关键事实」。 + * `## 关键事实` 从此**只由提炼产物填**(dream,29-DREAM)—— + * 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」。 + * 2. **滤掉确定的噪声**(isNoiseEntry),但不猜重要性 —— 猜重要性是 dream 的活。 + * 3. 不再截断成半句:留完整的第一行(上限放宽到 200),截断残渣读起来像事实、 + * 其实缺主语,比不留更坏。 + * + * 这一段同时是 **dream 的输入面**:它要提炼的就是这一段。 */ function appendToMemoryMd(agentId, task, result, meta) { if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return; + if (isNoiseEntry(task, result)) return; const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md'); let content = ''; @@ -176,27 +226,20 @@ function appendToMemoryMd(agentId, task, result, meta) { } catch { /* 文件不存在 */ } const timestamp = new Date().toISOString().substring(0, 10); - const taskOneLine = (task || '').split('\n')[0].substring(0, 80); - const resultOneLine = (result || '').split('\n')[0].substring(0, 100); + const firstLine = (t) => (t || '').split('\n').map((x) => x.trim()).find(Boolean) || ''; + const taskLine = firstLine(task).substring(0, 200); + const resultLine = firstLine(result).substring(0, 200); - // 构造新条目 - const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; + const entry = `- [${timestamp}] ${taskLine} → ${resultLine}`; - // 追加到"关键事实"段 - if (content.includes('## 关键事实')) { + if (content.includes(RAW_SECTION)) { content = content.replace( - /## 关键事实\n([\s\S]*?)(?=\n## |$)/, - (match, existing) => { - const trimmed = existing.trim(); - if (trimmed === '(尚无记录)') { - return `## 关键事实\n${entry}\n`; - } - return `## 关键事实\n${trimmed}\n${entry}\n`; - } + new RegExp(`${RAW_SECTION}\\n([\\s\\S]*?)(?=\\n## |$)`), + (match, existing) => `${RAW_SECTION}\n${existing.trim() ? `${existing.trim()}\n` : ''}${entry}\n` ); } else { - // 没有"关键事实"段,追加到末尾 - content += `\n\n## 关键事实\n${entry}\n`; + // 放在末尾:「关键事实 / 已验证的模式 / 失败教训」是提炼产物的位置,原始记录排在它们后面 + content = `${content.replace(/\s*$/, '')}\n\n${RAW_SECTION}\n${entry}\n`; } // 大小控制:超过限制时截断最早的条目 diff --git a/server/tests/unit/definition-write-gate.test.js b/server/tests/unit/definition-write-gate.test.js new file mode 100644 index 0000000..e32fd3b --- /dev/null +++ b/server/tests/unit/definition-write-gate.test.js @@ -0,0 +1,86 @@ +/** + * 改本体定义文件之前的 git 历史闸门(29-DREAM 决策 B)。 + * + * ── 守什么 ──────────────────────────────────────────────────────────── + * dream 直接改 `agent.md` / `soul.md` / `SKILL.md`,而「本体只有一份」—— + * 改一次影响**所有雇了这个本体的公司**。覆盖一份尚未进入 git 历史的内容, + * 等于让上一版永久消失,且**不报错**。 + * + * 两条判据都要满足才放行:① 被 git 跟踪;② 没有未提交改动。 + * 判不出来(不在仓库 / git 不可用)一律拒绝 —— fail-closed。 + * + * ── 为什么不是「脏就先替它提交一把」 ────────────────────────────────── + * 那正是本仓库记过的一类事故:多会话共用同一个 worktree 时,一份未提交的改动 + * 很可能是**另一个会话正在写的东西**(27-P3C §15.4)。替它提交 = 把别人的半成品 + * 写进 main 且署错名。所以拒绝并报告,把「要不要提交」留给人。 + * + * ── 隔离 ────────────────────────────────────────────────────────────── + * 在临时目录里 `git init` 一个真仓库来验,不碰本仓库的 git 状态。 + * 闸门的 REPO_ROOT 是模块级常量,所以用 `cwd` 覆盖不了 —— 改成对**真仓库里的真文件** + * 做正向断言,对负向分支用临时仓库 + 直接调内部判据。 + */ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; + +const gate = require('../../services/definition-write-gate'); + +const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + +describe('本体定义写入闸门 · git 历史', () => { + it('已跟踪且工作区干净 → 放行(拿真仓库里的真文件验,样本非空)', () => { + const f = path.join(gate.REPO_ROOT, 'system/agents/ceo/agent.md'); + expect(fs.existsSync(f)).toBe(true); // 样本非空:文件真的在,否则下面这条是空跑 + const v = gate.canOverwriteDefinition(f); + // 本仓库跑测试时这个文件应当是干净的;万一有人正在改它,这条会红 —— 那不是误报, + // 正是闸门要表达的意思(有未提交改动时就不该让 dream 覆盖) + expect(v.allowed).toBe(true); + }); + + it('没被跟踪 → 拒绝,且理由里说清「覆盖即永久丢失」和下一步', () => { + const f = path.join(gate.REPO_ROOT, 'system/agents/ceo/zz-not-tracked.md'); + const v = gate.canOverwriteDefinition(f); + expect(v.allowed).toBe(false); + expect(v.reason).toContain('没有被 git 跟踪'); + expect(v.reason).toContain('覆盖即永久丢失'); + expect(v.reason).toContain('commit'); // 拒绝必须给出下一步,否则撞上的人只能猜 + }); + + it('仓库之外的路径 → 拒绝(没有历史可依托)', () => { + const v = gate.canOverwriteDefinition('/tmp/somewhere/agent.md'); + expect(v.allowed).toBe(false); + expect(v.reason).toContain('不在仓库内'); + }); + + it('有未提交改动 → 拒绝(这一条用临时真仓库验,不碰本仓库状态)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'defgate-')); + try { + git(['init', '-q'], dir); + git(['config', 'user.email', 't@t'], dir); + git(['config', 'user.name', 't'], dir); + const f = path.join(dir, 'agent.md'); + fs.writeFileSync(f, '# v1\n'); + git(['add', 'agent.md'], dir); + git(['commit', '-qm', 'v1'], dir); + + // 干净时:git status 为空 + expect(git(['status', '--porcelain', '--', 'agent.md'], dir).trim()).toBe(''); + // 改脏之后:非空 —— 这正是闸门判「拒绝」的那个信号 + fs.writeFileSync(f, '# v2 未提交\n'); + expect(git(['status', '--porcelain', '--', 'agent.md'], dir).trim()).not.toBe(''); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('assertOverwritable 在不满足时抛,且带可识别的 code', () => { + expect(() => gate.assertOverwritable('/tmp/nope/agent.md')).toThrow(/拒绝改写本体定义/); + try { + gate.assertOverwritable('/tmp/nope/agent.md'); + } catch (e) { + expect(e.code).toBe('DEFINITION_NOT_IN_GIT_HISTORY'); + } + }); +}); diff --git a/server/tests/unit/memory-raw-section.test.js b/server/tests/unit/memory-raw-section.test.js new file mode 100644 index 0000000..79bbf9e --- /dev/null +++ b/server/tests/unit/memory-raw-section.test.js @@ -0,0 +1,94 @@ +/** + * `memory.md` 的写入:**原始记录不许冒充「关键事实」**(29-DREAM §1.1 / §四 步骤 1)。 + * + * ── 改之前是什么样 ──────────────────────────────────────────────────── + * `appendToMemoryMd` 取任务与结果的第一行、砍到 80/100 字符,拼一条写进 `## 关键事实`。 + * 那不是提炼,是搬运 + 截断。实测产物(`cto/_global/memory/memory.md`): + * + * - [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh...) + * + * **工具调用 id 被当成了「关键事实」**,而 memory.md 会被注入每一轮提示词。 + * + * ── 现在要守的三条 ──────────────────────────────────────────────────── + * ① 自动写入落在「原始记录(未提炼)」段,`## 关键事实` 一个字不动 —— + * 那一段留给 dream 的提炼产物,两者混在一起之后就分不清「日志」和「结论」了; + * ② 确定无信息量的条目(工具调用标记 / 空结果 / 心跳噪声)直接不写; + * ③ 不再截成半句 —— 截断残渣读起来像事实、其实缺主语,比不留更坏。 + */ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-memmd-')); +// memory-service 用 locator.companyStateDir(agentId) 定位;这里改成直接对着一个 +// 真实的本体+公司态目录跑(`zz-memmd:zz-memco`),afterAll 清掉。 +const AGENT = 'zz-memmd'; +const COMPANY = 'zz-memco'; +const ROOT = path.resolve(__dirname, '../../..'); +const STATE_DIR = path.join(ROOT, 'system', 'agents', AGENT, COMPANY); +const MEMORY_MD = path.join(STATE_DIR, 'memory', 'memory.md'); + +const ms = require('../../services/memory-service'); +const addr = `${COMPANY}:${AGENT}`; +const META = { stats: { toolCallCount: 3 } }; + +const TEMPLATE = '# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n## 已验证的模式\n\n## 失败教训\n'; + +describe('memory.md 写入 · 原始记录与关键事实分开', () => { + beforeEach(() => { + fs.mkdirSync(path.dirname(MEMORY_MD), { recursive: true }); + fs.writeFileSync(MEMORY_MD, TEMPLATE, 'utf-8'); + }); + + afterAll(() => { + fs.rmSync(path.join(ROOT, 'system', 'agents', AGENT), { recursive: true, force: true }); + fs.rmSync(TMP, { recursive: true, force: true }); + }); + + it('① 写进「原始记录(未提炼)」,关键事实段一个字不动', () => { + ms.appendToMemoryMd(addr, '给 X 公司做官网', '产出 index.html / styles.css,验收脚本先行', META); + const md = fs.readFileSync(MEMORY_MD, 'utf-8'); + + expect(md).toContain('## 原始记录(未提炼)'); + expect(md).toContain('给 X 公司做官网 → 产出 index.html'); + + // 关键事实那一段必须原样保留 —— 它留给 dream 的提炼产物 + const facts = md.split('## 关键事实')[1].split('## ')[0]; + expect(facts).toContain('刚入职,尚无记忆'); + expect(facts).not.toContain('给 X 公司做官网'); + }); + + it('② 工具调用标记 / 空结果 / 心跳噪声一条都不写', () => { + const before = fs.readFileSync(MEMORY_MD, 'utf-8'); + // 这三条正是实测在 cto/_global 里看到的形态 + ms.appendToMemoryMd(addr, '[SYSTEM HEARTBEAT]', '🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh5niCFyUZMFZj2hxF)', META); + ms.appendToMemoryMd(addr, '干点什么', '', META); + ms.appendToMemoryMd(addr, '干点什么', '…', META); + ms.appendToMemoryMd(addr, 'HEARTBEAT', '🔧 [TOOL_CALL] Read', META); + expect(fs.readFileSync(MEMORY_MD, 'utf-8')).toBe(before); + }); + + it('③ 不再截成半句:完整第一行留下来(旧实现砍到 80/100)', () => { + const task = '为公司 X 重写落地页:要求断网可 file:// 打开、不引任何外链、六档视口(320/390/768/1024/1440/2560)都不出现横向滚动、隐藏初态带 .js 前缀,验收以独立脚本的退出码为准,不认交付方自带的自检脚本'; + ms.appendToMemoryMd(addr, task, '按 A-1..A-14 逐条对照后通过', META); + const md = fs.readFileSync(MEMORY_MD, 'utf-8'); + expect(task.length).toBeGreaterThan(80); // 样本非空:这句确实超过旧上限 + expect(md).toContain(task); // 旧实现在这里只会留前 80 字 + }); + + it('多次写入累加在同一段里,不会每次新开一段', () => { + ms.appendToMemoryMd(addr, '任务甲', '结果甲甲甲甲甲', META); + ms.appendToMemoryMd(addr, '任务乙', '结果乙乙乙乙乙', META); + const md = fs.readFileSync(MEMORY_MD, 'utf-8'); + expect(md.match(/## 原始记录(未提炼)/g)).toHaveLength(1); + expect(md).toContain('任务甲'); + expect(md).toContain('任务乙'); + }); + + it('没有 toolCall 的会话不写(保持原有行为,别顺手放宽)', () => { + const before = fs.readFileSync(MEMORY_MD, 'utf-8'); + ms.appendToMemoryMd(addr, '只是聊了两句', '好的', { stats: { toolCallCount: 0 } }); + expect(fs.readFileSync(MEMORY_MD, 'utf-8')).toBe(before); + }); +});