Commit a0d8410e395e8f85d38037535cc7775256f5a2b5

Authored by yibo
1 parent 4075dcf1

feat(dream): 步骤 2/5 与步骤 3 的骨架 —— 公司清单查表、空转保护、去敏闸门、可追溯

## 步骤 2:「有哪些公司」只认 company_agents 表

system/agents/{aid}/{cid}/ 是**约定不是校验**。实查 96 个二级目录里混着
train/ workspace/ 和带时间戳的测试残留(tmp-w7-*-1786986291739-*)。
拿目录枚举公司 = 把测试夹具当经验原料喂给提炼 agent。

改成查表,另补保留段 _global(全局 agent 的运行态落点,没人「雇」它所以不在表里)。
**目录从此只用来定位文件,不用来枚举公司。**
守卫里真造了 train/ 和 tmp-w7-* 两个目录,断言它们不进原料。

## 空转保护:水位线按「条数」不按 mtime

实查 75 份 memory.md 只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。
不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱。

**判据用条数而不是 mtime**:mtime 会被任何一次无关写入推动(例如别处重写整份
memory.md),条数只在真的多出记录时增长。用 mtime 的话,空转保护会在
「文件被碰过但内容没变」时失效。

## 决策 A 的失效方向写进代码

去敏判定 agent **抛异常也算拒绝**,并把「为什么」写进 rejected[]。
否则现场只看到「少了一条」,查不出是判否还是判炸。
「少沉淀一条经验」与「泄漏一次机密」代价不对称,判据必须偏向贵的那一侧。

## 步骤 5:可追溯

每条原料带 source({公司}:memory.md#{行号}),提炼产物落盘时把 sources
写进 experience.md 的引用行。没有它,dream 的产出无法验证对错 ——
与 FDE 那边「LLM 自己汇报分数」是同一个问题。

## 步骤 3 还差什么(写在文档 §5.3,别当成做完了)

编排 / 空转 / 闸门 / 落盘 / 可追溯都跑通了(6 例守卫),
**缺两次真 agent 调用的默认实现**:提炼 agent 与去敏判定 agent 的本体还不存在。
默认实现现在是一句显式 throw(「尚未接线」)而**不是静默返回空** ——
静默返回空会让调用方以为「跑了但没提炼出东西」,那是本仓库反复出现的失效形态。

也还没写定义文件:writeDistilled 目前只写本体根的 memory/experience.md
(纯经验积累,删掉无副作用)。三个 git 跟踪的定义留给下一步,
definitionWritability() 已能报出「现在能不能写、不能写是为什么」。

全量:144 文件 / 1316 测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/29-dream-design.md
@@ -210,17 +210,18 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; @@ -210,17 +210,18 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`;
210 210
211 ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) 211 ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板)
212 212
213 -> **进度**:步骤 1(上游记忆写入)与步骤 4(定义文件闸门)已实现;2 / 3 / 5 未开工。 213 +> **进度**:步骤 1、2、4、5 已实现;步骤 3 只落了**骨架 + 注入缝**,
  214 +> 两次 agent 调用(提炼 / 去敏判定)的**默认实现尚未接线** —— 见 §5.3。
214 215
215 用户定的是两件一起做,不分先后。拆成可验收的步骤: 216 用户定的是两件一起做,不分先后。拆成可验收的步骤:
216 217
217 | 步 | 内容 | 为什么不能省 | 218 | 步 | 内容 | 为什么不能省 |
218 |---|---|---| 219 |---|---|---|
219 | 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | 220 | 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 |
220 -| 2 | **「有哪些公司」改查 `company_agents` 表**(§1.3) | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 |  
221 -| 3 | **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写 `memory/` + `SKILL.md` | 主体 | 221 +| 2 | ✅ **「有哪些公司」改查 `company_agents` 表**(§1.3)—— 已完成 | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 |
  222 +| 3 | 🟡 **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写落点 —— **骨架已落,两次 agent 调用未接线** | 主体 |
222 | 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 | 223 | 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 |
223 -| 5 | **可追溯**:每条沉淀记住它是从哪几条 session 提炼来的(决策 D 的便宜起步) | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | 224 +| 5 | ✅ **可追溯**:每条沉淀记住它是从哪几条记录提炼来的 —— 已完成 | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 |
224 225
225 **空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。 226 **空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。
226 227
@@ -251,3 +252,36 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; @@ -251,3 +252,36 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`;
251 守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、 252 守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、
252 长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、 253 长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、
253 无 toolCall 不写(保住原有行为,别顺手放宽)。 254 无 toolCall 不写(保住原有行为,别顺手放宽)。
  255 +
  256 +### 5.2 步骤 2 / 5 与 3 的骨架(`dream-service.js`)
  257 +
  258 +**步骤 2 —— 公司清单只认 `company_agents` 表。**
  259 +`companiesOf(agentId)` 查表,另把保留段 `_global` 补上(全局 agent 的运行态落在那里,
  260 +没人「雇」它,所以不在表里)。目录从此**只用来定位文件,不用来枚举公司**。
  261 +守卫里造了两个「看起来像公司」的目录(`train/` 与 `tmp-w7-bootstrap-1786986291739-9n2xv`),
  262 +断言它们不进原料 —— 那正是实查到的两种残留形态。
  263 +
  264 +**空转保护 —— 水位线按「条数」而不是 mtime。**
  265 +mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md`),条数只在真的多出记录时增长。
  266 +拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。
  267 +没有新原料时 `dreamOnce` 直接返回 `skipped: 'no-new-material'`,**一次 agent 调用都不起**。
  268 +
  269 +**步骤 5 —— 可追溯。**
  270 +每条原料带 `source`(`{公司}:memory.md#{行号}`),提炼产物落盘时把 `sources` 一并写进
  271 +`experience.md` 的引用行。人抽查时能顺着回去看。
  272 +
  273 +**决策 A 的失效方向在代码里是硬的**:判定 agent **抛异常也算拒绝**,
  274 +并把「为什么」写进 `rejected[]` —— 否则现场只看到「少了一条」,查不出是判否还是判炸。
  275 +
  276 +### 5.3 步骤 3 还差什么(别当成做完了)
  277 +
  278 +`setDistiller` / `setScreener` 两个注入缝已经在,`dreamOnce` 的编排、空转保护、
  279 +去敏闸门、落盘、可追溯都跑通了(6 例守卫)。**缺的是两次真 agent 调用的默认实现**:
  280 +
  281 +- 提炼 agent 与去敏判定 agent 的**本体还不存在**(需要两份 `agent.md` + `soul.md`);
  282 +- 默认实现现在是一句显式的 throw(「尚未接线」),**不是静默返回空** ——
  283 + 静默返回空会让调用方以为「跑了但没提炼出东西」,那是本仓库反复出现的失效形态。
  284 +
  285 +**还没写定义文件**:`writeDistilled` 目前只写本体根的 `memory/experience.md`
  286 +(纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义
  287 +留给下一步,`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」。
server/services/dream-service.js 0 → 100644
  1 +/**
  2 + * dream —— 经验汇流([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。
  3 + *
  4 + * 定时把**所有 agent 在所有公司**的原始记录提炼成不含公司特定信息的经验,
  5 + * 沉淀到本体上,让经验跨公司汇流而不泄漏任何一家的具体信息。
  6 + *
  7 + * ── 四条拍板(2026-08-20,§三)──────────────────────────────────────
  8 + * A 去敏 = 提炼 agent 按写作要求产出 + **独立的判定 agent** 过闸门。
  9 + * 失效方向是「少沉淀」不是「泄漏」—— 两种错代价不对称时判据偏向贵的那侧。
  10 + * B 落点 = 本体根 `memory/`(跨公司那一份)与 `SKILL.md` 都写;
  11 + * `soul.md` / `agent.md` 直接落盘,但写前必须过 definition-write-gate。
  12 + * C 触发 = 定时每日一次。
  13 + * D 顺序 = 上游记忆写入与 dream 一起做。
  14 + *
  15 + * ── 两条贯穿全程的判据 ──────────────────────────────────────────────
  16 + * **① 「有哪些公司」只认 `company_agents` 表,不扫目录。**
  17 + * `system/agents/{aid}/{cid}/` 是约定不是校验,实查 96 个二级目录里混着
  18 + * `train/` `workspace/` 和带时间戳的测试残留(`tmp-w7-*-1786986291739-*`)。
  19 + * 拿目录枚举公司 = 把它们当原料。目录只用来**定位文件**,不用来**枚举公司**。
  20 + *
  21 + * **② 空转不许起 agent 调用。**
  22 + * 实查 75 份 memory.md 里只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。
  23 + * 不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱,日志里全是噪声。
  24 + * 判据是水位线(`dream.json` 的 `lastRunAt`)与原料的 mtime 比对。
  25 + */
  26 +
  27 +const fs = require('fs');
  28 +const path = require('path');
  29 +const locator = require('./agent-locator');
  30 +const { canOverwriteDefinition } = require('./definition-write-gate');
  31 +
  32 +const RAW_SECTION = '## 原始记录(未提炼)';
  33 +/** 本体根上的跨公司经验落点(决策 B) */
  34 +const CROSS_COMPANY_MEMORY = 'memory';
  35 +const STATE_FILE = 'dream.json';
  36 +
  37 +// ── 注入缝:两次 agent 调用都可替换(写法照抄 graph-engine.setAgentExecutor)──
  38 +let distiller = null;
  39 +let screener = null;
  40 +/** 替换「提炼」实现(传 null 回默认实现) */
  41 +function setDistiller(fn) { distiller = fn; }
  42 +/** 替换「去敏判定」实现(传 null 回默认实现) */
  43 +function setScreener(fn) { screener = fn; }
  44 +
  45 +/**
  46 + * 这个本体在哪些公司有过运行态?**答案来自 `company_agents` 表**(判据①)。
  47 + *
  48 + * `_global` 单独补上:它是保留段,全局 agent 的运行态落在那里,
  49 + * 不在 company_agents 里(那张表记的是「公司雇了谁」,全局 agent 没人雇)。
  50 + * 算不算「一家公司」在设计里是 ❓,这里的处理是**算原料、但在提炼时标明它是全局态**。
  51 + */
  52 +function companiesOf(agentId) {
  53 + const out = [];
  54 + try {
  55 + const { listCompanyAgents } = require('./company-service');
  56 + for (const row of listCompanyAgents()) {
  57 + if (row.system_agent_id === agentId || row.role_name === agentId) out.push(row.company_id);
  58 + }
  59 + } catch (err) {
  60 + // 库还没建好不该让 dream 整个挂掉,但**必须让调用方知道这一轮的原料是不全的**
  61 + console.warn(`[Dream] ⚠️ 读 company_agents 失败,本轮只看 _global: ${err.message}`);
  62 + }
  63 + if (fs.existsSync(path.join(locator.bodyDir(agentId), '_global'))) out.push('_global');
  64 + return [...new Set(out)];
  65 +}
  66 +
  67 +/** 一条原料:正文 + 它从哪来(决策 D 的「可追溯」起步) */
  68 +function readRawEntries(agentId, companyId) {
  69 + const md = path.join(locator.companyDir(agentId, companyId), 'memory', 'memory.md');
  70 + let text;
  71 + try { text = fs.readFileSync(md, 'utf-8'); } catch { return []; }
  72 + const idx = text.indexOf(RAW_SECTION);
  73 + if (idx < 0) return [];
  74 + const after = text.slice(idx + RAW_SECTION.length);
  75 + const section = after.split(/\n## /)[0];
  76 + return section
  77 + .split('\n')
  78 + .map((l) => l.trim())
  79 + .filter((l) => l.startsWith('- '))
  80 + .map((l, i) => ({ agentId, companyId, source: `${companyId}:memory.md#${i + 1}`, text: l.slice(2).trim() }));
  81 +}
  82 +
  83 +/** 本体根的 dream 状态(水位线 + 上一轮产出) */
  84 +function statePath(agentId) {
  85 + return path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, STATE_FILE);
  86 +}
  87 +function readState(agentId) {
  88 + try { return JSON.parse(fs.readFileSync(statePath(agentId), 'utf-8')); } catch { return {}; }
  89 +}
  90 +function writeState(agentId, state) {
  91 + const p = statePath(agentId);
  92 + fs.mkdirSync(path.dirname(p), { recursive: true });
  93 + fs.writeFileSync(p, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
  94 +}
  95 +
  96 +/**
  97 + * 采集这个本体的全部新原料。
  98 + *
  99 + * **水位线按「条数」而不是 mtime**:mtime 会被任何一次无关写入(例如另一段代码
  100 + * 重写整份 memory.md)推动,而条数只在真的多出记录时才增长。
  101 + * 拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。
  102 + */
  103 +function collectMaterial(agentId) {
  104 + const state = readState(agentId);
  105 + const seen = state.seenCounts || {};
  106 + const fresh = [];
  107 + const counts = {};
  108 + for (const cid of companiesOf(agentId)) {
  109 + const entries = readRawEntries(agentId, cid);
  110 + counts[cid] = entries.length;
  111 + const from = seen[cid] || 0;
  112 + if (entries.length > from) fresh.push(...entries.slice(from));
  113 + }
  114 + return { fresh, counts };
  115 +}
  116 +
  117 +/**
  118 + * 跑一轮 dream。
  119 + *
  120 + * @param {string} agentId 本体 id
  121 + * @param {{dryRun?: boolean}} opts
  122 + * @returns {Promise<object>} 这一轮做了什么(含 skipped 的原因,别让调用方猜)
  123 + */
  124 +async function dreamOnce(agentId, { dryRun = false } = {}) {
  125 + const { fresh, counts } = collectMaterial(agentId);
  126 +
  127 + // 判据②:没有新原料就**一次 agent 调用都不起**
  128 + if (fresh.length === 0) {
  129 + return { agentId, skipped: 'no-new-material', counts, distilled: 0, accepted: 0 };
  130 + }
  131 +
  132 + const candidates = await (distiller || defaultDistiller)({ agentId, entries: fresh });
  133 + const accepted = [];
  134 + const rejected = [];
  135 + for (const c of candidates || []) {
  136 + // 决策 A:**独立**的判定,不与提炼共享上下文;判不准 = 拒绝(fail-closed)
  137 + let verdict;
  138 + try {
  139 + verdict = await (screener || defaultScreener)({ agentId, candidate: c });
  140 + } catch (err) {
  141 + rejected.push({ ...c, reason: `判定失败,按拒绝处理:${err.message}` });
  142 + continue;
  143 + }
  144 + if (verdict && verdict.shareable === true) accepted.push(c);
  145 + else rejected.push({ ...c, reason: (verdict && verdict.reason) || '判定未通过' });
  146 + }
  147 +
  148 + if (!dryRun && accepted.length) writeDistilled(agentId, accepted);
  149 + if (!dryRun) {
  150 + writeState(agentId, {
  151 + ...readState(agentId),
  152 + seenCounts: counts,
  153 + lastRunAt: new Date().toISOString(),
  154 + lastAccepted: accepted.length,
  155 + lastRejected: rejected.length,
  156 + });
  157 + }
  158 + return { agentId, skipped: null, counts, distilled: (candidates || []).length, accepted: accepted.length, rejected };
  159 +}
  160 +
  161 +/**
  162 + * 落盘(决策 B)。
  163 + *
  164 + * 只写**本体根的 `memory/`**:那是纯经验积累,删掉即可,无副作用。
  165 + * `SKILL.md` / `soul.md` / `agent.md` 是 git 跟踪的**定义**,
  166 + * 每一份都必须先过 `definition-write-gate` —— 覆盖一份尚未进入 git 历史的内容
  167 + * 等于让上一版永久消失,且不报错。这里只做**闸门检查 + 记录**,
  168 + * 真正改定义留给后续步骤(本轮不写定义文件,见 §五 实现记录)。
  169 + *
  170 + * **每条沉淀都带 sources**(决策 D):没有它,dream 的产出无法验证对错。
  171 + */
  172 +function writeDistilled(agentId, accepted) {
  173 + const dir = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY);
  174 + fs.mkdirSync(dir, { recursive: true });
  175 + const file = path.join(dir, 'experience.md');
  176 + const now = new Date().toISOString().substring(0, 10);
  177 + const blocks = accepted.map((c) => [
  178 + `### ${c.title || '(未命名)'}`,
  179 + '',
  180 + c.body || '',
  181 + '',
  182 + `> 提炼于 ${now};原料:${(c.sources || []).join('、') || '(未记录)'}`,
  183 + '',
  184 + ].join('\n'));
  185 + const head = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8').replace(/\s*$/, '') : '# 跨公司经验(dream 提炼)';
  186 + fs.writeFileSync(file, `${head}\n\n${blocks.join('\n')}`, 'utf-8');
  187 + return file;
  188 +}
  189 +
  190 +/** 定义文件能不能写 —— 给后续步骤用,也给控制台显示「为什么没改」 */
  191 +function definitionWritability(agentId) {
  192 + const body = locator.bodyDir(agentId);
  193 + return ['agent.md', 'soul.md', 'SKILL.md'].map((f) => ({
  194 + file: f, ...canOverwriteDefinition(path.join(body, f)),
  195 + }));
  196 +}
  197 +
  198 +async function defaultDistiller() {
  199 + throw new Error('[Dream] 提炼实现尚未接线(29-DREAM §四 步骤 3):请注入 setDistiller 或等待默认实现落地');
  200 +}
  201 +async function defaultScreener() {
  202 + throw new Error('[Dream] 去敏判定实现尚未接线(29-DREAM §四 步骤 3):请注入 setScreener 或等待默认实现落地');
  203 +}
  204 +
  205 +module.exports = {
  206 + dreamOnce, collectMaterial, companiesOf, readRawEntries,
  207 + writeDistilled, definitionWritability, readState, writeState,
  208 + setDistiller, setScreener,
  209 + RAW_SECTION, CROSS_COMPANY_MEMORY,
  210 +};
server/tests/unit/dream-service.test.js 0 → 100644
  1 +/**
  2 + * dream 的骨架(29-DREAM §四 步骤 2 / 3 / 5)。
  3 + *
  4 + * ── 三条判据各盯一个已知会出事的地方 ──────────────────────────────────
  5 + * ① **「有哪些公司」只认 `company_agents` 表,不扫目录。**
  6 + * `system/agents/{aid}/{cid}/` 是约定不是校验:实查 96 个二级目录里混着
  7 + * `train/` `workspace/` 和带时间戳的测试残留(`tmp-w7-*-1786986291739-*`)。
  8 + * 拿目录枚举公司 = 把测试夹具当经验原料喂给提炼 agent。
  9 + * ② **空转不许起 agent 调用。**
  10 + * 实查 75 份 memory.md 只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。
  11 + * 不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱。
  12 + * ③ **去敏判定失败 = 拒绝**(决策 A 的失效方向)。
  13 + * 「少沉淀一条经验」与「泄漏一次机密」代价不对称,判据必须偏向贵的那一侧。
  14 + *
  15 + * 外加可追溯(决策 D):每条沉淀都要带得回原料。
  16 + */
  17 +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
  18 +import fs from 'fs';
  19 +import os from 'os';
  20 +import path from 'path';
  21 +
  22 +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-dream-'));
  23 +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite');
  24 +
  25 +const ROOT = path.resolve(__dirname, '../../..');
  26 +const AGENT = 'zz-dream-body';
  27 +const CO_A = 'zz-dream-co-a';
  28 +const CO_B = 'zz-dream-co-b';
  29 +const BODY = path.join(ROOT, 'system', 'agents', AGENT);
  30 +
  31 +const dream = require('../../services/dream-service');
  32 +const wdb = require('../../services/work-graph-db');
  33 +
  34 +function seedMemory(companyId, lines) {
  35 + const dir = path.join(BODY, companyId, 'memory');
  36 + fs.mkdirSync(dir, { recursive: true });
  37 + fs.writeFileSync(path.join(dir, 'memory.md'),
  38 + `# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n${dream.RAW_SECTION}\n${lines.map((l) => `- ${l}`).join('\n')}\n`,
  39 + 'utf-8');
  40 +}
  41 +
  42 +function hire(companyId) {
  43 + const db = wdb.getDb();
  44 + const now = new Date().toISOString();
  45 + db.prepare(`INSERT OR IGNORE INTO system_agents
  46 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  47 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`)
  48 + .run(AGENT, AGENT, `system/agents/${AGENT}`, now, now);
  49 + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
  50 + VALUES (?, ?, ?, 'active', ?)`).run(companyId, AGENT, AGENT, now);
  51 +}
  52 +
  53 +describe('dream · 骨架', () => {
  54 + beforeEach(() => {
  55 + fs.rmSync(BODY, { recursive: true, force: true });
  56 + dream.setDistiller(null);
  57 + dream.setScreener(null);
  58 + });
  59 +
  60 + afterAll(() => {
  61 + fs.rmSync(BODY, { recursive: true, force: true });
  62 + fs.rmSync(TMP, { recursive: true, force: true });
  63 + delete process.env.GRAPH_DB_PATH;
  64 + });
  65 +
  66 + it('① 公司清单来自 company_agents 表 —— 目录里的测试残留不算公司', () => {
  67 + hire(CO_A);
  68 + seedMemory(CO_A, ['给 A 做官网 → 验收脚本先行']);
  69 + // 目录里再造两个「看起来像公司」的:一个是 ArenaSK 时代的运行态,一个是带时间戳的测试残留
  70 + for (const junk of ['train', 'tmp-w7-bootstrap-1786986291739-9n2xv']) {
  71 + seedMemory(junk, ['这条不该被当成经验原料']);
  72 + }
  73 + const companies = dream.companiesOf(AGENT);
  74 + expect(companies).toContain(CO_A);
  75 + expect(companies).not.toContain('train');
  76 + expect(companies).not.toContain('tmp-w7-bootstrap-1786986291739-9n2xv');
  77 +
  78 + const { fresh } = dream.collectMaterial(AGENT);
  79 + expect(fresh.map((f) => f.text)).toEqual(['给 A 做官网 → 验收脚本先行']);
  80 + });
  81 +
  82 + it('② 没有新原料 → 一次 agent 调用都不起', async () => {
  83 + hire(CO_A);
  84 + seedMemory(CO_A, ['第一条']);
  85 + const distill = vi.fn(async () => []);
  86 + dream.setDistiller(distill);
  87 +
  88 + const first = await dream.dreamOnce(AGENT);
  89 + expect(first.skipped).toBeNull();
  90 + expect(distill).toHaveBeenCalledTimes(1);
  91 +
  92 + // 原料没变 → 第二轮直接跳过,且**说清为什么**(别让调用方猜)
  93 + const second = await dream.dreamOnce(AGENT);
  94 + expect(second.skipped).toBe('no-new-material');
  95 + expect(distill).toHaveBeenCalledTimes(1);
  96 +
  97 + // 多了一条才继续,且只喂**增量**(不是整段重来)
  98 + seedMemory(CO_A, ['第一条', '第二条']);
  99 + dream.setDistiller(async ({ entries }) => {
  100 + expect(entries.map((e) => e.text)).toEqual(['第二条']);
  101 + return [];
  102 + });
  103 + expect((await dream.dreamOnce(AGENT)).skipped).toBeNull();
  104 + });
  105 +
  106 + it('③ 去敏判定不通过 / 抛异常,都不沉淀(失效方向是「少沉淀」)', async () => {
  107 + hire(CO_A);
  108 + seedMemory(CO_A, ['一条经验']);
  109 + dream.setDistiller(async () => [
  110 + { title: '通过的', body: '抽象经验', sources: ['a'] },
  111 + { title: '被拒的', body: '含公司名', sources: ['b'] },
  112 + { title: '判定炸了的', body: '???', sources: ['c'] },
  113 + ]);
  114 + dream.setScreener(async ({ candidate }) => {
  115 + if (candidate.title === '判定炸了的') throw new Error('判定 agent 挂了');
  116 + return { shareable: candidate.title === '通过的', reason: '含公司特定信息' };
  117 + });
  118 +
  119 + const r = await dream.dreamOnce(AGENT);
  120 + expect(r.distilled).toBe(3);
  121 + expect(r.accepted).toBe(1);
  122 + expect(r.rejected).toHaveLength(2);
  123 + // 判定抛异常那条必须留下**为什么**,否则现场只看到「少了一条」
  124 + expect(r.rejected.find((x) => x.title === '判定炸了的').reason).toContain('判定失败');
  125 +
  126 + const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8');
  127 + expect(exp).toContain('通过的');
  128 + expect(exp).not.toContain('被拒的');
  129 + });
  130 +
  131 + it('每条沉淀都带得回原料(决策 D 的可追溯起步)', async () => {
  132 + hire(CO_A);
  133 + seedMemory(CO_A, ['素材一']);
  134 + dream.setDistiller(async ({ entries }) => [
  135 + { title: '经验甲', body: '正文', sources: entries.map((e) => e.source) },
  136 + ]);
  137 + dream.setScreener(async () => ({ shareable: true }));
  138 + await dream.dreamOnce(AGENT);
  139 + const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8');
  140 + expect(exp).toContain(`${CO_A}:memory.md#1`);
  141 + });
  142 +
  143 + it('两家公司的原料都进来,且各自带自己的出处(跨公司汇流的最小形态)', async () => {
  144 + hire(CO_A); hire(CO_B);
  145 + seedMemory(CO_A, ['A 的经验']);
  146 + seedMemory(CO_B, ['B 的经验']);
  147 + const seenSources = [];
  148 + dream.setDistiller(async ({ entries }) => {
  149 + seenSources.push(...entries.map((e) => e.source));
  150 + return [];
  151 + });
  152 + await dream.dreamOnce(AGENT);
  153 + expect(seenSources).toEqual(expect.arrayContaining([`${CO_A}:memory.md#1`, `${CO_B}:memory.md#1`]));
  154 + });
  155 +
  156 + it('默认实现没接线时抛得明白(别让调用方以为「跑了但没产出」)', async () => {
  157 + hire(CO_A);
  158 + seedMemory(CO_A, ['一条']);
  159 + await expect(dream.dreamOnce(AGENT)).rejects.toThrow(/提炼实现尚未接线/);
  160 + });
  161 +});