Commit 1d20af1b01826829ac2799892d5f4f7231ede794

Authored by yibo
1 parent abd65859

feat(dream): 第一次真 CLI 跑通 + 存量记忆搬家脚本

## 前置:存量记忆的搬家(19 文件 / 269 行)

写入侧上一轮改了,但**存量不会自己搬家**。实查现有 `## 关键事实` 里
**没有一条真事实** —— 全是重复心跳行、工具调用 id、和以 `...` 开头的半句。
而 memory.md 会被注入**每一轮**提示词,这些东西一直在冒充事实。

scripts/migrate-memory-raw-section.js 把它们搬进「原始记录(未提炼)」。
先 dry-run 再 --apply,改前逐个 .bak(这些是 gitignore 的运行态,没有 git 历史兜底)。

判据刻意窄:只搬 `- [YYYY-MM-DD] … → …` 这一种形状(旧自动追加的唯一形状)。
**搬错比留错更坏** —— 留下最多多一条噪声,搬错是把人写的东西降级成日志。

## 真跑:innovator-evolver,40 条原料

    { "distilled": 4, "accepted": 4, "rejected": [] }   耗时 114s

四条产出都做到了抽象:yt-dlp / Invidious → 「本地下载工具 → 备用镜像源」;
scorer.js / SKILL_v1.md / system/templates/... 一个都没出现。

**顺着 sources 抽查过**(可追溯的第一次实际用途):4 条引用全部对得上原料。
「按关键词覆盖率打分会被刷到虚高分」那条引了 #21/#22/#27/#28,而 #27 原文正是
「从零重写,采用 expected_output 逐 case rubric + dimCeil 上限机制,杜绝 coverage-based」。
**它是跨 4 条记录归纳出来的**,不是逐条改写 —— 正是「一次调用吃掉全部原料」
那个选择要换来的东西。

## 闸门单独验过:零拒绝 ≠ 闸门有效

真跑里 4 条全过,说明去敏闸门**一次都没触发**。所以单独喂了三条给 dream-screener 真跑:
· 含公司名+域名+绝对路径 → false,理由点名「域名 lemonai.ai 和绝对路径 /Users/…」
· 心跳日志原文摘录 → false,理由点名「原文搬运」「工具调用 ID 可关联到具体运行实例」
· 干净的抽象经验 → true

三条判据都按 agent.md 写的那样生效,**且拒绝理由都点到了具体是哪几个字** ——
那是 agent.md 里明确要求的。

## 空转保护实跑

第二次调用(原料没变):{"skipped":"no-new-material"},一次 agent 调用都没起。

## 还没做的

定时挂载(决策 C)、写定义文件(SKILL.md / soul.md / agent.md)、
决策 D(质量怎么评 —— §5.5 那次抽查是**人工**顺着 sources 回查的,不是自动的)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/29-dream-design.md
... ... @@ -210,8 +210,8 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`;
210 210  
211 211 ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板)
212 212  
213   -> **进度**:步骤 1、2、3、4、5 全部实现。**未做的是:定时挂载(决策 C)、
214   -> 写定义文件(`SKILL.md` / `soul.md` / `agent.md`)、以及一次真 CLI 验收** —— 见 §5.4。
  213 +> **进度**:步骤 1、2、3、4、5 全部实现,**并已真 CLI 跑通一次**(§5.5)。
  214 +> 未做的是:定时挂载(决策 C)、写定义文件(`SKILL.md` / `soul.md` / `agent.md`)—— 见 §5.4。
215 215  
216 216 用户定的是两件一起做,不分先后。拆成可验收的步骤:
217 217  
... ... @@ -311,8 +311,64 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md
311 311 (纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义
312 312 留给下一步;`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」,
313 313 闸门(§B.3)也已就位,缺的是「往里写什么、写成什么形状」这半边设计。
314   -3. **没有一次真 CLI 验收**。CI 里换掉的是 `callDreamAgent` 那一层,
315   - 验的是「回复 → 结构化产物」的解析与失效方向,**不是模型写得好不好**。
316   - 两个本体的提示词至今**没有被任何模型读过** —— 这正是 P3B W4 记过的形态
317   - (mock 从不读提示词,所以提示词在第一次真跑前从未被检验)。
318   -4. **决策 D(提炼质量怎么评)仍未定**。可追溯(`sources`)是它的便宜起步,不是替代品。
  314 +3. **决策 D(提炼质量怎么评)仍未定**。可追溯(`sources`)是它的便宜起步,不是替代品。
  315 + §5.5 那次抽查是**人工**顺着 sources 回查的,不是自动的。
  316 +
  317 +---
  318 +
  319 +## 5.5 第一次真 CLI 跑通(2026-08-20)
  320 +
  321 +### 前置:存量记忆的搬家
  322 +
  323 +写入侧改了(§5.1),但**存量不会自己搬家**。实查现有 `## 关键事实` 里
  324 +**没有一条真事实** —— 全是重复的心跳行、工具调用 id、和以 `...` 开头的半句:
  325 +
  326 +```
  327 +- [2026-08-18] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01M1t4...)
  328 +- [2026-04-09] [TASK from master] → ...ates/community-manager/train/evolve/SKILL_v1.md` |
  329 +```
  330 +
  331 +而 `memory.md` 会被注入**每一轮**提示词 —— 这些东西一直在冒充事实。
  332 +`scripts/migrate-memory-raw-section.js` 把它们搬进「原始记录(未提炼)」:
  333 +**19 个文件 / 269 行**,先 dry-run 再 `--apply`,改前逐个 `.bak`
  334 +(这些是 gitignore 的运行态,没有 git 历史兜底)。
  335 +
  336 +判据刻意窄:只搬 `- [YYYY-MM-DD] … → …` 这一种形状(旧自动追加的唯一形状)。
  337 +人手写的事实不长这样,不动它 —— **搬错比留错更坏**:留下最多多一条噪声,
  338 +搬错是把人写的东西降级成日志。
  339 +
  340 +### 真跑结果
  341 +
  342 +对 `innovator-evolver`(40 条原料,本仓库最厚的一份)跑 `dreamOnce`:
  343 +
  344 +```
  345 +{ "distilled": 4, "accepted": 4, "rejected": [] } 耗时 114 s
  346 +```
  347 +
  348 +四条产出**都做到了抽象**,原料里的具体名字全被抽掉:
  349 +`yt-dlp` / `Invidious` → 「本地下载工具 → 备用镜像源」;
  350 +`scorer.js` / `SKILL_v1.md` / `system/templates/...` 一个都没出现。
  351 +
  352 +**顺着 `sources` 抽查过**(可追溯的第一次实际用途):4 条引用全部对得上原料。
  353 +例如「按关键词覆盖率打分会被刷到虚高分」那条引了 #21/#22/#27/#28,
  354 +而 #27 原文正是「从零重写,采用 `expected_output` 逐 case rubric + `dimCeil` 上限机制,
  355 +杜绝 coverage-based」。**它是跨 4 条记录归纳出来的**,不是逐条改写 ——
  356 +这正是「一次调用吃掉全部原料」那个选择要换来的东西。
  357 +
  358 +### 闸门单独验过(这一轮零拒绝 = 没被考验)
  359 +
  360 +真跑里 4 条全过,说明**去敏闸门一次都没触发**。零拒绝不等于闸门有效,
  361 +所以单独喂了三条给 `dream-screener` 真跑:
  362 +
  363 +| 输入 | 判定 | 理由(原文) |
  364 +|---|---|---|
  365 +| 含公司名 + 域名 + 绝对路径 | ❌ false | 「出现了具体域名 lemonai.ai 和绝对路径 /Users/…,第三方能据此直接认出是哪家公司/哪台机器」 |
  366 +| 心跳日志原文摘录 | ❌ false | 「是心跳日志的原文摘录,不是重写过的经验,属于原文搬运;且工具调用 ID 是可能关联到具体运行实例的标识符」 |
  367 +| 干净的抽象经验 | ✅ true | 「没有公司名/路径/URL…读起来是重写过的原则而非原始对话摘录」 |
  368 +
  369 +三条判据(可识别主体 / 原文搬运 / 正常放行)都按 agent.md 写的那样生效了,
  370 +**而且拒绝理由都点到了具体是哪几个字** —— 那是 agent.md 里明确要求的。
  371 +
  372 +### 空转保护实跑
  373 +
  374 +第二次调用(原料没变):`{"skipped":"no-new-material"}`,**一次 agent 调用都没起**。
... ...
scripts/migrate-memory-raw-section.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 一次性迁移:把 `memory.md` 里**自动追加**的那些行,从 `## 关键事实`
  4 + * 搬到 `## 原始记录(未提炼)`。
  5 + *
  6 + * ── 为什么 ────────────────────────────────────────────────────────────
  7 + * 旧的 `appendToMemoryMd` 取任务与结果的第一行、砍到 80/100 字符,写进「关键事实」。
  8 + * 那不是提炼,是搬运 + 截断。实查(2026-08-20)现有内容里**没有一条真事实**:
  9 + *
  10 + * - [2026-08-18] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01M1t4...)
  11 + * - [2026-04-09] [TASK from master] → ...ates/community-manager/train/evolve/SKILL_v1.md` |
  12 + *
  13 + * 前者是工具调用 id,后者是**半句**(`...` 开头,主语被截掉了)。
  14 + * 而 `memory.md` 会被注入**每一轮**提示词 —— 也就是说这些东西一直在冒充事实。
  15 + *
  16 + * 写入侧已改(29-DREAM §四 步骤 1),但**存量不会自己搬家**。这个脚本补那一步。
  17 + *
  18 + * ── 判据(刻意窄)────────────────────────────────────────────────────
  19 + * 只搬形如 `- [YYYY-MM-DD] … → …` 的行 —— 那是旧自动追加的**唯一**形状。
  20 + * 人手写的事实不长这样,不动它。判不准就不搬:留在原地最多是「多了一条噪声」,
  21 + * 搬错则是把人写的东西降级成日志。
  22 + *
  23 + * ── 用法 ──────────────────────────────────────────────────────────────
  24 + * node scripts/migrate-memory-raw-section.js # dry-run,只打印
  25 + * node scripts/migrate-memory-raw-section.js --apply # 真改(先自动备份到 .bak)
  26 + */
  27 +
  28 +const fs = require('fs');
  29 +const path = require('path');
  30 +
  31 +const ROOT = path.resolve(__dirname, '..');
  32 +const AGENTS = path.join(ROOT, 'system', 'agents');
  33 +const RAW_SECTION = '## 原始记录(未提炼)';
  34 +/** 旧自动追加的唯一形状 */
  35 +const AUTO_LINE = /^-\s*\[\d{4}-\d{2}-\d{2}\]\s.*→/;
  36 +
  37 +const apply = process.argv.includes('--apply');
  38 +
  39 +/** 找出所有 memory.md(本体根 + 公司态两层都可能有) */
  40 +function findMemoryFiles() {
  41 + const out = [];
  42 + const walk = (dir, depth) => {
  43 + if (depth > 3) return;
  44 + let entries;
  45 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
  46 + for (const e of entries) {
  47 + const p = path.join(dir, e.name);
  48 + if (e.isDirectory()) walk(p, depth + 1);
  49 + else if (e.name === 'memory.md' && path.basename(dir) === 'memory') out.push(p);
  50 + }
  51 + };
  52 + walk(AGENTS, 0);
  53 + return out;
  54 +}
  55 +
  56 +function migrate(text) {
  57 + const factsIdx = text.indexOf('## 关键事实');
  58 + if (factsIdx < 0) return null;
  59 + const head = text.slice(0, factsIdx);
  60 + const rest = text.slice(factsIdx + '## 关键事实'.length);
  61 + const body = rest.split(/\n## /)[0];
  62 + const tail = rest.slice(body.length);
  63 +
  64 + const lines = body.split('\n');
  65 + const moved = lines.filter((l) => AUTO_LINE.test(l.trim()));
  66 + if (moved.length === 0) return null;
  67 + const kept = lines.filter((l) => !AUTO_LINE.test(l.trim()));
  68 +
  69 + // 「关键事实」清空后要留占位,否则下游读到空段会以为文件坏了
  70 + const keptText = kept.join('\n').trim() || '\n_(尚无提炼产物)_\n';
  71 + let out = `${head}## 关键事实\n${keptText.startsWith('\n') ? keptText : `\n${keptText}\n`}${tail}`;
  72 +
  73 + // 合并进已有的原始记录段;没有就补一个(放最后,提炼产物的位置在前)
  74 + if (out.includes(RAW_SECTION)) {
  75 + out = out.replace(
  76 + new RegExp(`${RAW_SECTION}\\n([\\s\\S]*?)(?=\\n## |$)`),
  77 + (m, existing) => `${RAW_SECTION}\n${moved.join('\n')}\n${existing.trim() ? `${existing.trim()}\n` : ''}`
  78 + );
  79 + } else {
  80 + out = `${out.replace(/\s*$/, '')}\n\n${RAW_SECTION}\n${moved.join('\n')}\n`;
  81 + }
  82 + return { out, movedCount: moved.length };
  83 +}
  84 +
  85 +let files = 0; let lines = 0;
  86 +for (const f of findMemoryFiles()) {
  87 + const text = fs.readFileSync(f, 'utf-8');
  88 + const r = migrate(text);
  89 + if (!r) continue;
  90 + files += 1; lines += r.movedCount;
  91 + console.log(`${apply ? '改' : '待改'} ${path.relative(ROOT, f)} 搬 ${r.movedCount} 行`);
  92 + if (apply) {
  93 + fs.writeFileSync(`${f}.bak`, text, 'utf-8'); // 这些是 gitignore 的运行态,没有 git 历史兜底
  94 + fs.writeFileSync(f, r.out, 'utf-8');
  95 + }
  96 +}
  97 +console.log(`\n${apply ? '已改' : 'dry-run'}:${files} 个文件 / ${lines} 行`);
  98 +if (!apply && files) console.log('要真改:node scripts/migrate-memory-raw-section.js --apply');
... ...