/** * dream —— 经验汇流([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。 * * 定时把**所有 agent 在所有公司**的原始记录提炼成不含公司特定信息的经验, * 沉淀到本体上,让经验跨公司汇流而不泄漏任何一家的具体信息。 * * ── 四条拍板(2026-08-20,§三)────────────────────────────────────── * A 去敏 = 提炼 agent 按写作要求产出 + **独立的判定 agent** 过闸门。 * 失效方向是「少沉淀」不是「泄漏」—— 两种错代价不对称时判据偏向贵的那侧。 * B 落点 = 本体根 `memory/`(跨公司那一份)与 `SKILL.md` 都写; * `soul.md` / `agent.md` 直接落盘,但写前必须过 definition-write-gate。 * C 触发 = 定时每日一次。 * D 顺序 = 上游记忆写入与 dream 一起做。 * * ── 两条贯穿全程的判据 ────────────────────────────────────────────── * **① 「有哪些公司」只认 `company_agents` 表,不扫目录。** * `system/agents/{aid}/{cid}/` 是约定不是校验,实查 96 个二级目录里混着 * `train/` `workspace/` 和带时间戳的测试残留(`tmp-w7-*-1786986291739-*`)。 * 拿目录枚举公司 = 把它们当原料。目录只用来**定位文件**,不用来**枚举公司**。 * * **② 空转不许起 agent 调用。** * 实查 75 份 memory.md 里只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。 * 不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱,日志里全是噪声。 * 判据是水位线(`dream.json` 的 `lastRunAt`)与原料的 mtime 比对。 */ const fs = require('fs'); const path = require('path'); const locator = require('./agent-locator'); const { canOverwriteDefinition } = require('./definition-write-gate'); const RAW_SECTION = '## 原始记录(未提炼)'; /** 本体根上的跨公司经验落点(决策 B) */ const CROSS_COMPANY_MEMORY = 'memory'; const STATE_FILE = 'dream.json'; // ── 注入缝:两次 agent 调用都可替换(写法照抄 graph-engine.setAgentExecutor)── let distiller = null; let screener = null; /** 替换「提炼」实现(传 null 回默认实现) */ function setDistiller(fn) { distiller = fn; } /** 替换「去敏判定」实现(传 null 回默认实现) */ function setScreener(fn) { screener = fn; } /** * 这个本体在哪些公司有过运行态?**答案来自 `company_agents` 表**(判据①)。 * * `_global` 单独补上:它是保留段,全局 agent 的运行态落在那里, * 不在 company_agents 里(那张表记的是「公司雇了谁」,全局 agent 没人雇)。 * 算不算「一家公司」在设计里是 ❓,这里的处理是**算原料、但在提炼时标明它是全局态**。 */ function companiesOf(agentId) { const out = []; try { const { listCompanyAgents } = require('./company-service'); for (const row of listCompanyAgents()) { if (row.system_agent_id === agentId || row.role_name === agentId) out.push(row.company_id); } } catch (err) { // 库还没建好不该让 dream 整个挂掉,但**必须让调用方知道这一轮的原料是不全的** console.warn(`[Dream] ⚠️ 读 company_agents 失败,本轮只看 _global: ${err.message}`); } if (fs.existsSync(path.join(locator.bodyDir(agentId), '_global'))) out.push('_global'); return [...new Set(out)]; } /** 一条原料:正文 + 它从哪来(决策 D 的「可追溯」起步) */ function readRawEntries(agentId, companyId) { const md = path.join(locator.companyDir(agentId, companyId), 'memory', 'memory.md'); let text; try { text = fs.readFileSync(md, 'utf-8'); } catch { return []; } const idx = text.indexOf(RAW_SECTION); if (idx < 0) return []; const after = text.slice(idx + RAW_SECTION.length); const section = after.split(/\n## /)[0]; return section .split('\n') .map((l) => l.trim()) .filter((l) => l.startsWith('- ')) .map((l, i) => ({ agentId, companyId, source: `${companyId}:memory.md#${i + 1}`, text: l.slice(2).trim() })); } /** 本体根的 dream 状态(水位线 + 上一轮产出) */ function statePath(agentId) { return path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, STATE_FILE); } function readState(agentId) { try { return JSON.parse(fs.readFileSync(statePath(agentId), 'utf-8')); } catch { return {}; } } function writeState(agentId, state) { const p = statePath(agentId); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); } /** * 采集这个本体的全部新原料。 * * **水位线按「条数」而不是 mtime**:mtime 会被任何一次无关写入(例如另一段代码 * 重写整份 memory.md)推动,而条数只在真的多出记录时才增长。 * 拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。 */ function collectMaterial(agentId) { const state = readState(agentId); const seen = state.seenCounts || {}; const fresh = []; const counts = {}; for (const cid of companiesOf(agentId)) { const entries = readRawEntries(agentId, cid); counts[cid] = entries.length; const from = seen[cid] || 0; if (entries.length > from) fresh.push(...entries.slice(from)); } return { fresh, counts }; } /** * 跑一轮 dream。 * * @param {string} agentId 本体 id * @param {{dryRun?: boolean}} opts * @returns {Promise} 这一轮做了什么(含 skipped 的原因,别让调用方猜) */ async function dreamOnce(agentId, { dryRun = false } = {}) { const { fresh, counts } = collectMaterial(agentId); // 判据②:没有新原料就**一次 agent 调用都不起** if (fresh.length === 0) { return { agentId, skipped: 'no-new-material', counts, distilled: 0, accepted: 0 }; } const candidates = await (distiller || defaultDistiller)({ agentId, entries: fresh }); const accepted = []; const rejected = []; for (const c of candidates || []) { // 决策 A:**独立**的判定,不与提炼共享上下文;判不准 = 拒绝(fail-closed) let verdict; try { verdict = await (screener || defaultScreener)({ agentId, candidate: c }); } catch (err) { rejected.push({ ...c, reason: `判定失败,按拒绝处理:${err.message}` }); continue; } if (verdict && verdict.shareable === true) accepted.push(c); else rejected.push({ ...c, reason: (verdict && verdict.reason) || '判定未通过' }); } if (!dryRun && accepted.length) writeDistilled(agentId, accepted); if (!dryRun) { writeState(agentId, { ...readState(agentId), seenCounts: counts, lastRunAt: new Date().toISOString(), lastAccepted: accepted.length, lastRejected: rejected.length, }); } return { agentId, skipped: null, counts, distilled: (candidates || []).length, accepted: accepted.length, rejected }; } /** * 落盘(决策 B)。 * * 只写**本体根的 `memory/`**:那是纯经验积累,删掉即可,无副作用。 * `SKILL.md` / `soul.md` / `agent.md` 是 git 跟踪的**定义**, * 每一份都必须先过 `definition-write-gate` —— 覆盖一份尚未进入 git 历史的内容 * 等于让上一版永久消失,且不报错。这里只做**闸门检查 + 记录**, * 真正改定义留给后续步骤(本轮不写定义文件,见 §五 实现记录)。 * * **每条沉淀都带 sources**(决策 D):没有它,dream 的产出无法验证对错。 */ function writeDistilled(agentId, accepted) { const dir = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, 'experience.md'); const now = new Date().toISOString().substring(0, 10); const blocks = accepted.map((c) => [ `### ${c.title || '(未命名)'}`, '', c.body || '', '', `> 提炼于 ${now};原料:${(c.sources || []).join('、') || '(未记录)'}`, '', ].join('\n')); const head = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8').replace(/\s*$/, '') : '# 跨公司经验(dream 提炼)'; fs.writeFileSync(file, `${head}\n\n${blocks.join('\n')}`, 'utf-8'); return file; } /** 定义文件能不能写 —— 给后续步骤用,也给控制台显示「为什么没改」 */ function definitionWritability(agentId) { const body = locator.bodyDir(agentId); return ['agent.md', 'soul.md', 'SKILL.md'].map((f) => ({ file: f, ...canOverwriteDefinition(path.join(body, f)), })); } async function defaultDistiller() { throw new Error('[Dream] 提炼实现尚未接线(29-DREAM §四 步骤 3):请注入 setDistiller 或等待默认实现落地'); } async function defaultScreener() { throw new Error('[Dream] 去敏判定实现尚未接线(29-DREAM §四 步骤 3):请注入 setScreener 或等待默认实现落地'); } module.exports = { dreamOnce, collectMaterial, companiesOf, readRawEntries, writeDistilled, definitionWritability, readState, writeState, setDistiller, setScreener, RAW_SECTION, CROSS_COMPANY_MEMORY, };