/** * `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); }); });