dream-service.js
9.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
/**
* 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,
};