dream-service.js 9.17 KB
/**
 * 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<object>} 这一轮做了什么(含 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,
};