memory-raw-section.test.js 5.21 KB
/**
 * `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);
  });
});