sage-mem 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/dream.js DELETED
@@ -1,886 +0,0 @@
1
- /**
2
- * sage-mem dream —— 「做梦」:让记忆自己整理一遍。
3
- *
4
- * 一趟 dream 做四件事:定位(看清已有什么)→ 收集(找新信号)→ 整合(合并、修正)
5
- * → 修剪(索引与断链)。下面是三个非显然的设计决定:
6
- *
7
- * 1. **门控三级,最便宜的先查**:
8
- * 时间门(一次读写状态)→ 会话门(一次目录扫描,且带 10 分钟节流)→ 锁。
9
- * 任何一级不过,后面的都不做 —— 顺序就是成本顺序。
10
- * 2. **执行不走真正的子代理**,直接 `ctx.llm.stream()` 手写一个极简 agent 循环。
11
- * 换来的是:工具集由我们给(安全边界在 dream-tools.js)、步数有上限、
12
- * 不需要 agent 生命周期与 provider 注册,**也不会把 dream 写进用户的会话记录**。
13
- * 3. **运行前快照 + 运行后审计**。memory 目录不在任何版本控制下、删除没有回收站,
14
- * 所以「可回滚」必须自己造;审计则让每趟 dream 自证没改坏。
15
- *
16
- * 配置与状态同一个 JSON 文件(`<memoryDir>/../.sage-mem/dream.json`),
17
- * 手改也方便——它是纯文本,且和人读的东西放在一起。
18
- */
19
-
20
- import { readFile, writeFile, readdir, mkdir, copyFile, rm, stat, open, rename } from 'node:fs/promises'
21
- import { join, dirname, basename } from 'node:path'
22
- import { randomUUID } from 'node:crypto'
23
- import { auditMemoryDir, formatAudit } from './dream-audit.js'
24
- import { dreamToolSchemas, createToolRunner } from './dream-tools.js'
25
- import { buildDreamSystemPrompt, buildDreamUserPrompt } from './dream-prompt.js'
26
-
27
- /** 配置结构版本:将来加字段时用来做迁移。 */
28
- const CONFIG_VERSION = 1
29
-
30
- /** 默认配置。每一项都能从设置面板改;空 provider/model 表示跟聊天页当前默认模型。 */
31
- const DEFAULT_CONFIG = {
32
- enabled: false,
33
- trigger: 'manual',
34
- apply: false,
35
- source: 'memory',
36
- minHours: 24,
37
- minSessions: 5,
38
- provider: '',
39
- model: '',
40
- maxSteps: 24,
41
- maxSnapshotKeep: 5,
42
- }
43
-
44
- /**
45
- * 会话门的扫描节流(10 分钟)。
46
- *
47
- * 为什么需要它:时间门过了、会话门没过时,状态里的 lastRunAt 不会前进,
48
- * 于是每一次触发都会重新扫一遍 sessions 目录(本机 441 个会话目录)。
49
- * 节流把「看着像没过」和「真的没过」区分开——这中间的成本差异全是白烧的盘 IO。
50
- */
51
- const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000
52
-
53
- /** 自动模式的检查间隔。门控本身很便宜(有节流),所以这个频率是安全的。 */
54
- const SCHEDULE_INTERVAL_MS = 30 * 60 * 1000
55
-
56
- /** 启动后第一次自动检查的延迟——避开 DSH 启动时的那阵忙碌。 */
57
- const STARTUP_CHECK_DELAY_MS = 90 * 1000
58
-
59
- /** 锁的失效时间:超过这个时长还挂着的锁,认定是上次进程崩了留下的。 */
60
- const LOCK_STALE_MS = 2 * 60 * 60 * 1000
61
-
62
- /** 失败后的退避:别让一个每次都失败的任务把门控刷成高频重试。 */
63
- const FAILURE_BACKOFF_MS = 60 * 60 * 1000
64
-
65
- /** 报告目录名(相对 memory 根)。放在子目录里,所以不会被 sage-mem 自己扫成记忆。 */
66
- const REPORT_DIR = 'dream'
67
-
68
- /** 状态根目录名(相对 memory 根的上一层)。 */
69
- const STATE_DIR = '.sage-mem'
70
-
71
- /** 一次 dream 允许累计的输入 token 上限(软保护,超了就停)。 */
72
- const DEFAULT_TOKEN_BUDGET = 600000
73
-
74
- const clampNum = (v, lo, hi, dflt) => (Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt)
75
-
76
- /** 本地时间戳,给文件名用。 */
77
- function stampCompact(ms) {
78
- const d = new Date(ms)
79
- const p = (n) => String(n).padStart(2, '0')
80
- return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
81
- }
82
-
83
- /** 本地时间戳,给人看。 */
84
- function stampHuman(ms) {
85
- const d = new Date(ms)
86
- const p = (n) => String(n).padStart(2, '0')
87
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
88
- }
89
-
90
- /**
91
- * 精简版 chunk 组装器。
92
- *
93
- * 算法与 `@deepseek-ai/dsh-llm` 的 `BlockAssembler` 一致(读源码得来),
94
- * 只保留 dream 用得上的两种块,并**自己实现而不是 import** —— 树外插件
95
- * 依赖宿主内部包要过依赖桥,版本漂移时挂的是「整个插件加载不了」这种级别的
96
- * 故障,而这里需要的逻辑只有三十行。
97
- *
98
- * 两个必须照抄的行为:
99
- * - delta-only 协议(没有 block-start/block-end)也要能组装
100
- * - `finish.kind === 'max-tokens'` 时**丢掉 tool-call 块**:被截断的调用参数
101
- * 是不完整的 JSON,拿去执行等于凭半句话动手
102
- */
103
- class BlockAssembler {
104
- constructor() {
105
- this.partials = new Map()
106
- this.order = []
107
- this._usage = null
108
- this._finish = null
109
- }
110
-
111
- push(chunk) {
112
- switch (chunk?.type) {
113
- case 'block-start': {
114
- if (!this.partials.has(chunk.index)) {
115
- this.order.push(chunk.index)
116
- this.partials.set(chunk.index, { blockType: chunk.blockType, text: '', args: '' })
117
- }
118
- return
119
- }
120
- case 'text-delta': {
121
- const p = this.ensure(chunk.index, 'text')
122
- if (p.block) return
123
- p.text += chunk.text
124
- return
125
- }
126
- case 'reasoning-delta':
127
- return
128
- case 'tool-call-delta': {
129
- const p = this.ensure(chunk.index, 'tool-call')
130
- if (p.block) return
131
- p.callId = chunk.id
132
- if (chunk.name) p.callName = chunk.name
133
- p.args += chunk.argumentsDelta
134
- return
135
- }
136
- case 'block-end': {
137
- const p = this.ensure(chunk.index, chunk.block?.type ?? 'text')
138
- if (p.block) return
139
- p.block = chunk.block
140
- return
141
- }
142
- case 'usage':
143
- this._usage = chunk.usage
144
- return
145
- case 'finish':
146
- this._finish = chunk.reason
147
- return
148
- default:
149
- return
150
- }
151
- }
152
-
153
- ensure(index, blockType) {
154
- let p = this.partials.get(index)
155
- if (!p) {
156
- p = { blockType, text: '', args: '' }
157
- this.partials.set(index, p)
158
- this.order.push(index)
159
- }
160
- return p
161
- }
162
-
163
- assemble(p, index) {
164
- if (p.block) return p.block
165
- switch (p.blockType) {
166
- case 'text':
167
- return { type: 'text', text: p.text }
168
- case 'reasoning':
169
- return { type: 'reasoning', text: p.text }
170
- case 'tool-call':
171
- return { type: 'tool-call', id: p.callId ?? `call-${index}`, name: p.callName ?? '', arguments: p.args }
172
- default:
173
- return null
174
- }
175
- }
176
-
177
- blocks() {
178
- const all = this.order.map((i) => this.assemble(this.partials.get(i), i)).filter(Boolean)
179
- if (this.finish.kind === 'max-tokens') return all.filter((b) => b.type !== 'tool-call')
180
- return all
181
- }
182
-
183
- get usage() {
184
- return this._usage
185
- }
186
-
187
- get finish() {
188
- return this._finish ?? { kind: 'stop' }
189
- }
190
- }
191
-
192
- /** 造一条 user 消息(字段形状照 dsh-llm 的 createUserMessage,只是不引那个包)。 */
193
- function userMessage(text) {
194
- return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'sage-mem-dream' }, id: randomUUID() }
195
- }
196
-
197
- /** 造一条 assistant 消息,把模型这一轮的块原样回灌进历史。 */
198
- function assistantMessage(blocks, provider, model) {
199
- return {
200
- role: 'assistant',
201
- content: blocks,
202
- source: { kind: 'model', provider, model },
203
- id: randomUUID(),
204
- }
205
- }
206
-
207
- /** 造一条工具结果消息。callId 是把结果接回那次调用的唯一凭据。 */
208
- function toolMessage(callId, text, isError) {
209
- const msg = {
210
- role: 'tool',
211
- content: [{ type: 'text', text }],
212
- source: { kind: 'tool', callId },
213
- toolCallId: callId,
214
- id: randomUUID(),
215
- }
216
- if (isError) msg.isError = true
217
- return msg
218
- }
219
-
220
- /** 数一下 `$DSH_HOME/sessions` 下 mtime 晚于 sinceMs 的会话(只看文件时间,不解压)。 */
221
- async function countSessionsSince(root, sinceMs) {
222
- if (!root) return 0
223
- let n = 0
224
- let projects
225
- try {
226
- projects = await readdir(root, { withFileTypes: true })
227
- } catch {
228
- return 0
229
- }
230
- for (const p of projects) {
231
- if (!p.isDirectory()) continue
232
- const projPath = join(root, p.name)
233
- let sessions
234
- try {
235
- sessions = await readdir(projPath, { withFileTypes: true })
236
- } catch {
237
- continue
238
- }
239
- for (const s of sessions) {
240
- if (!s.isDirectory()) continue
241
- const file = join(projPath, s.name, 'session.v4.jsonl.zstd')
242
- try {
243
- const info = await stat(file)
244
- if (info.mtimeMs >= sinceMs) n++
245
- } catch {
246
- // 目录里没有这个文件:不是每一代格式都叫这个名字,跳过即可。
247
- }
248
- }
249
- }
250
- return n
251
- }
252
-
253
- export class DreamEngine {
254
- /**
255
- * @param {object} ctx — 插件上下文(Cordis)
256
- * @param {{memoryDir:string, sessionsRoot:string, version:string}} opts
257
- */
258
- constructor(ctx, opts) {
259
- this.ctx = ctx
260
- this.memoryDir = opts.memoryDir
261
- this.sessionsRoot = opts.sessionsRoot
262
- this.version = opts.version
263
- this.stateRoot = join(dirname(this.memoryDir), STATE_DIR)
264
- this.configPath = join(this.stateRoot, 'dream.json')
265
- this.lockPath = join(this.stateRoot, 'dream.lock')
266
-
267
- this.running = false
268
- this.phase = ''
269
- this.startedAt = 0
270
- this.stepLog = []
271
- this.lastScanAt = 0
272
- this.state = { config: { ...DEFAULT_CONFIG }, version: CONFIG_VERSION, lastRunAt: 0, lastResult: null, retryAfter: 0 }
273
- this.disposers = []
274
- this._configLoaded = false
275
- }
276
-
277
- /** 服务解析:优先 ctx.get(),回落直接属性(不同 Cordis 版本两种都在用)。 */
278
- svc(name) {
279
- const viaGet = typeof this.ctx?.get === 'function' ? this.ctx.get(name) : undefined
280
- return viaGet ?? this.ctx?.[name]
281
- }
282
-
283
- log(level, msg) {
284
- const line = `sage-mem dream: ${msg}`
285
- const logger = this.ctx?.logger
286
- if (level === 'error' && logger?.error) logger.error(line)
287
- else if (logger?.warn) logger.warn(line)
288
- else console.warn(line)
289
- }
290
-
291
- // ────────────────────────────── 配置与状态 ──────────────────────────────
292
-
293
- /** 读配置 + 状态(同一个文件)。缺失或损坏都回落到默认值,绝不让它拖死插件加载。 */
294
- async load() {
295
- if (this._configLoaded) return this.state
296
- try {
297
- const raw = await readFile(this.configPath, 'utf8')
298
- const parsed = JSON.parse(raw)
299
- this.state = {
300
- version: CONFIG_VERSION,
301
- config: { ...DEFAULT_CONFIG, ...(parsed?.config ?? {}) },
302
- lastRunAt: Number(parsed?.lastRunAt) || 0,
303
- lastResult: parsed?.lastResult ?? null,
304
- retryAfter: Number(parsed?.retryAfter) || 0,
305
- }
306
- } catch (err) {
307
- if (err?.code !== 'ENOENT') {
308
- this.log('warn', `配置文件读取失败,改用默认值(${this.configPath}):${err?.message ?? err}`)
309
- }
310
- this.state = { version: CONFIG_VERSION, config: { ...DEFAULT_CONFIG }, lastRunAt: 0, lastResult: null, retryAfter: 0 }
311
- }
312
- this._configLoaded = true
313
- return this.state
314
- }
315
-
316
- /** 落盘。写入是原子的(临时文件 + rename),半截文件不会留在盘上。 */
317
- async persist() {
318
- await mkdir(this.stateRoot, { recursive: true })
319
- const tmp = `${this.configPath}.${process.pid}.tmp`
320
- try {
321
- await writeFile(tmp, JSON.stringify(this.state, null, 2), 'utf8')
322
- await rename(tmp, this.configPath)
323
- } finally {
324
- // rename 成功后 tmp 已不存在(ENOENT),失败时清掉不留垃圾。
325
- await rm(tmp, { force: true }).catch(() => {})
326
- }
327
- }
328
-
329
- /** 给 UI 的完整配置(含有效模型路线与门控快照)。 */
330
- async getConfig() {
331
- await this.load()
332
- const route = this.resolveRouteSafe(this.state.config)
333
- return {
334
- config: { ...this.state.config },
335
- route,
336
- lastRunAt: this.state.lastRunAt,
337
- lastResult: this.state.lastResult,
338
- paths: { memoryDir: this.memoryDir, configPath: this.configPath, stateRoot: this.stateRoot },
339
- defaults: { ...DEFAULT_CONFIG },
340
- }
341
- }
342
-
343
- /** 改配置。逐项做类型与范围收敛——UI 传什么进来都不该写出一份坏配置。 */
344
- async setConfig(patch) {
345
- await this.load()
346
- const p = patch && typeof patch === 'object' ? patch : {}
347
- const c = { ...this.state.config }
348
- if (typeof p.enabled === 'boolean') c.enabled = p.enabled
349
- if (p.trigger === 'auto' || p.trigger === 'manual') c.trigger = p.trigger
350
- if (typeof p.apply === 'boolean') c.apply = p.apply
351
- if (p.source === 'memory' || p.source === 'memory+sessions') c.source = p.source
352
- if (p.minHours !== undefined) c.minHours = clampNum(Number(p.minHours), 1, 720, DEFAULT_CONFIG.minHours)
353
- if (p.minSessions !== undefined) c.minSessions = Math.round(clampNum(Number(p.minSessions), 1, 200, DEFAULT_CONFIG.minSessions))
354
- if (p.maxSteps !== undefined) c.maxSteps = Math.round(clampNum(Number(p.maxSteps), 1, 80, DEFAULT_CONFIG.maxSteps))
355
- if (p.maxSnapshotKeep !== undefined)
356
- c.maxSnapshotKeep = Math.round(clampNum(Number(p.maxSnapshotKeep), 1, 50, DEFAULT_CONFIG.maxSnapshotKeep))
357
- if (typeof p.provider === 'string') c.provider = p.provider.trim().slice(0, 120)
358
- if (typeof p.model === 'string') c.model = p.model.trim().slice(0, 160)
359
- this.state.config = c
360
- await this.persist()
361
- return await this.getConfig()
362
- }
363
-
364
- /** 解析这一趟该用哪条模型路线;解不出来就返回 null 而不是抛。 */
365
- resolveRouteSafe(config) {
366
- try {
367
- const sel = this.svc('agentDefaultModel')?.currentSelection?.()
368
- const provider = (config?.provider || sel?.provider || '').trim()
369
- const model = (config?.model || sel?.model || '').trim()
370
- if (!provider || !model) return null
371
- return { provider, model, fromDefault: !config?.provider && !config?.model }
372
- } catch {
373
- return null
374
- }
375
- }
376
-
377
- /** 当前状态快照,供设置面板轮询。 */
378
- async status() {
379
- await this.load()
380
- const cfg = this.state.config
381
- const lastAt = this.state.lastRunAt
382
- const hoursSince = lastAt ? (Date.now() - lastAt) / 3600000 : null
383
- return {
384
- running: this.running,
385
- phase: this.phase,
386
- startedAt: this.startedAt,
387
- steps: this.stepLog,
388
- config: { ...cfg },
389
- route: this.resolveRouteSafe(cfg),
390
- lastRunAt: lastAt,
391
- hoursSince,
392
- lastResult: this.state.lastResult,
393
- retryAfter: this.state.retryAfter,
394
- gate: cfg.enabled && cfg.trigger === 'auto' ? { minHours: cfg.minHours, minSessions: cfg.minSessions } : null,
395
- paths: { memoryDir: this.memoryDir, configPath: this.configPath, stateRoot: this.stateRoot },
396
- }
397
- }
398
-
399
- // ────────────────────────────── 门控 ──────────────────────────────
400
-
401
- /**
402
- * 三级门控(顺序即成本顺序)。
403
- * @returns {Promise<{ok:boolean, reason?:string, hoursSince?:number, sessionCount?:number}>}
404
- */
405
- async gate() {
406
- await this.load()
407
- const cfg = this.state.config
408
- if (!cfg.enabled) return { ok: false, reason: '未启用' }
409
- if (cfg.trigger !== 'auto') return { ok: false, reason: '手动模式(不会自动触发)' }
410
-
411
- const now = Date.now()
412
- if (this.state.retryAfter && now < this.state.retryAfter) {
413
- return { ok: false, reason: `上次失败后的退避中(还有 ${Math.ceil((this.state.retryAfter - now) / 60000)} 分钟)` }
414
- }
415
-
416
- // ① 时间门
417
- const lastAt = this.state.lastRunAt
418
- const hoursSince = lastAt ? (now - lastAt) / 3600000 : Number.POSITIVE_INFINITY
419
- if (hoursSince < cfg.minHours) {
420
- return { ok: false, reason: `距上次整理 ${hoursSince.toFixed(1)}h < ${cfg.minHours}h` }
421
- }
422
-
423
- // ② 扫描节流(在真正扫盘之前)
424
- if (now - this.lastScanAt < SESSION_SCAN_INTERVAL_MS) {
425
- return { ok: false, reason: `扫描节流中(${Math.round((now - this.lastScanAt) / 1000)}s 前刚扫过)` }
426
- }
427
- this.lastScanAt = now
428
-
429
- // ③ 会话门
430
- const sessionCount = await countSessionsSince(this.sessionsRoot, lastAt || 0)
431
- if (sessionCount < cfg.minSessions) {
432
- return { ok: false, reason: `期间只有 ${sessionCount} 个会话更新 < ${cfg.minSessions}` }
433
- }
434
- return { ok: true, hoursSince, sessionCount }
435
- }
436
-
437
- // ────────────────────────────── 锁 ──────────────────────────────
438
-
439
- async acquireLock() {
440
- await mkdir(this.stateRoot, { recursive: true })
441
- try {
442
- const fh = await open(this.lockPath, 'wx')
443
- await fh.write(JSON.stringify({ pid: process.pid, at: Date.now() }))
444
- await fh.close()
445
- return true
446
- } catch (err) {
447
- if (err?.code !== 'EEXIST') throw err
448
- const info = await stat(this.lockPath).catch(() => null)
449
- if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
450
- // 上次进程崩了留下的锁:清掉重来(不然 dream 永远回不来)。
451
- await rm(this.lockPath, { force: true })
452
- return this.acquireLock()
453
- }
454
- return false
455
- }
456
- }
457
-
458
- async releaseLock() {
459
- await rm(this.lockPath, { force: true }).catch(() => {})
460
- }
461
-
462
- // ────────────────────────────── 快照 ──────────────────────────────
463
-
464
- /**
465
- * 把 memory 目录顶层的 markdown 整体快照一份。
466
- *
467
- * 只快照顶层 `.md`:`archive/` 子目录是退役区(不需要每次跟着复制),
468
- * `dream/` 是报告区(自己就是产物)。
469
- * @returns {Promise<{dir:string, count:number}>}
470
- */
471
- async snapshot() {
472
- const dir = join(this.stateRoot, 'snapshots', stampCompact(Date.now()))
473
- await mkdir(dir, { recursive: true })
474
- const entries = await readdir(this.memoryDir, { withFileTypes: true }).catch(() => [])
475
- const files = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
476
- let count = 0
477
- for (const f of files) {
478
- try {
479
- await copyFile(join(this.memoryDir, f.name), join(dir, f.name))
480
- count++
481
- } catch (err) {
482
- this.log('warn', `快照 ${f.name} 失败:${err?.message ?? err}`)
483
- }
484
- }
485
- await writeFile(
486
- join(dir, 'MANIFEST.json'),
487
- JSON.stringify({ createdAt: Date.now(), createdAtHuman: stampHuman(Date.now()), files: count }, null, 2),
488
- 'utf8',
489
- )
490
- await this.pruneSnapshots()
491
- return { dir, count }
492
- }
493
-
494
- /** 只保留最近 N 份快照(N = 配置里的 maxSnapshotKeep)。 */
495
- async pruneSnapshots() {
496
- const keep = Math.max(1, this.state.config.maxSnapshotKeep || DEFAULT_CONFIG.maxSnapshotKeep)
497
- const root = join(this.stateRoot, 'snapshots')
498
- const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
499
- const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
500
- for (const name of dirs.slice(0, Math.max(0, dirs.length - keep))) {
501
- await rm(join(root, name), { recursive: true, force: true }).catch(() => {})
502
- }
503
- }
504
-
505
- /** 列出已有快照(设置面板会显示「可回退到哪一份」)。 */
506
- async listSnapshots() {
507
- const root = join(this.stateRoot, 'snapshots')
508
- const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
509
- const out = []
510
- for (const e of entries) {
511
- if (!e.isDirectory()) continue
512
- const manifest = await readFile(join(root, e.name, 'MANIFEST.json'), 'utf8')
513
- .then(JSON.parse)
514
- .catch(() => null)
515
- out.push({
516
- name: e.name,
517
- at: manifest?.createdAt ?? 0,
518
- atHuman: manifest?.createdAtHuman ?? e.name,
519
- files: manifest?.files ?? 0,
520
- path: join(root, e.name),
521
- })
522
- }
523
- out.sort((a, b) => b.name.localeCompare(a.name))
524
- return out
525
- }
526
-
527
- // ────────────────────────────── LLM 循环 ──────────────────────────────
528
-
529
- /**
530
- * 跑一次模型调用,返回组装好的块。
531
- * @returns {Promise<BlockAssembler>}
532
- */
533
- async streamOnce(llm, req) {
534
- const asm = new BlockAssembler()
535
- const options = {
536
- provider: req.provider,
537
- model: req.model,
538
- system: req.system,
539
- messages: req.messages,
540
- maxTokens: req.maxTokens,
541
- signal: req.signal,
542
- purpose: 'session-title', // 复用「非交互式调用」的语义槽,避免被当成主对话轮次记账
543
- }
544
- if (req.tools && req.tools.length) options.tools = req.tools
545
- for await (const chunk of llm.stream(options)) {
546
- asm.push(chunk)
547
- }
548
- return asm
549
- }
550
-
551
- /**
552
- * 极简 agent 循环:模型 → 工具 → 结果回灌 → 再问,直到模型不再要工具或触顶。
553
- *
554
- * 每一步都把「这一轮要了什么工具」记进 stepLog,设置面板显示的就是这个。
555
- */
556
- async agentLoop(cfg, runOpts) {
557
- const { provider, model, toolSchemas, runner, maxSteps, tokenBudget, signal } = runOpts
558
- const llm = this.svc('llm')
559
- if (!llm || typeof llm.stream !== 'function') throw new Error('llm 服务不可用,dream 无法运行')
560
-
561
- const system = buildDreamSystemPrompt({ apply: cfg.apply, withSessions: cfg.source === 'memory+sessions' })
562
- const userText = buildDreamUserPrompt({
563
- memoryDir: this.memoryDir,
564
- apply: cfg.apply,
565
- hoursSince: runOpts.hoursSince ?? 0,
566
- sessionCount: runOpts.sessionCount ?? 0,
567
- now: Date.now(),
568
- })
569
- const messages = [userMessage(userText)]
570
-
571
- const transcript = []
572
- let tokensIn = 0
573
- let tokensOut = 0
574
- let finalText = ''
575
- const allToolsUsed = []
576
-
577
- for (let step = 1; step <= maxSteps; step++) {
578
- if (signal?.aborted) throw new Error('dream 被取消')
579
- this.phase = `第 ${step} 轮:等待模型`
580
- const asm = await this.streamOnce(llm, {
581
- provider,
582
- model,
583
- system,
584
- messages,
585
- tools: toolSchemas,
586
- maxTokens: 4096,
587
- signal,
588
- })
589
- const usage = asm.usage
590
- if (usage) {
591
- tokensIn += usage.inputTokens ?? 0
592
- tokensOut += usage.outputTokens ?? 0
593
- }
594
- const blocks = asm.blocks()
595
- const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
596
- const calls = blocks.filter((b) => b.type === 'tool-call')
597
- if (text) finalText = text
598
- transcript.push({ step, text, tools: calls.map((c) => c.name) })
599
- this.stepLog.push({ step, tools: calls.map((c) => c.name), chars: text.length, at: Date.now() })
600
-
601
- if (asm.finish.kind === 'error') {
602
- throw new Error(`模型返回错误:${asm.finish.failure?.message ?? '未知'}`)
603
- }
604
- if (!calls.length) break
605
-
606
- messages.push(assistantMessage(blocks, provider, model))
607
- for (const call of calls) {
608
- let args = {}
609
- try {
610
- args = call.arguments ? JSON.parse(call.arguments) : {}
611
- } catch {
612
- args = {}
613
- }
614
- this.phase = `第 ${step} 轮:${call.name}`
615
- const out = await runner.run(call.name, args)
616
- allToolsUsed.push(call.name)
617
- messages.push(toolMessage(call.id, out, /^(错误|工具执行失败)/.test(out)))
618
- }
619
-
620
- if (tokensIn + tokensOut > tokenBudget) {
621
- transcript.push({ step: step + 1, text: `(已达 token 预算 ${tokenBudget},提前收工)`, tools: [] })
622
- break
623
- }
624
- }
625
-
626
- return { transcript, finalText, tokensIn, tokensOut, toolsUsed: allToolsUsed, touched: runner.touchedFiles() }
627
- }
628
-
629
- // ────────────────────────────── 报告 ──────────────────────────────
630
-
631
- /** 写一份报告到 `memory/dream/YYYYMMDD-HHmmss.md`。 */
632
- async writeReport(payload) {
633
- const dir = join(this.memoryDir, REPORT_DIR)
634
- await mkdir(dir, { recursive: true })
635
- // 直接整串用 —— stampCompact 返回的就是 `20260927-134209` 这种。
636
- // 曾经在这里按位置切片想拼成 `YYYY-MM-DD-HHmmss`,结果 slice(0,10) 从时间戳里
637
- // 借走了两位,写出 `20260927-1-4209.md` 这种看着像坏了的名字(2026-09-27 实测)。
638
- const name = `${stampCompact(payload.startedAt)}.md`
639
- const file = join(dir, name)
640
-
641
- const lines = [
642
- `# Dream 报告 · ${stampHuman(payload.startedAt)}`,
643
- '',
644
- `- 触发:${payload.reason}`,
645
- `- 模式:${payload.apply ? '改写记忆(已快照)' : '只出报告'}`,
646
- `- 输入源:${payload.source === 'memory+sessions' ? '记忆目录 + 会话记录' : '仅记忆目录'}`,
647
- `- 模型:\`${payload.provider}/${payload.model}\``,
648
- `- 门控:距上次 ${payload.hoursSince === null ? '(首次)' : payload.hoursSince.toFixed(1) + 'h'} · 期间 ${payload.sessionCount} 个会话更新`,
649
- `- 耗时:${((payload.endedAt - payload.startedAt) / 1000).toFixed(1)}s · token ${payload.tokensIn} in / ${payload.tokensOut} out`,
650
- payload.snapshot ? `- 运行前快照:\`${payload.snapshot.dir}\`(${payload.snapshot.count} 个文件)` : '- 运行前快照:未做(只读模式)',
651
- '',
652
- '## 改动的文件',
653
- '',
654
- payload.touched.length ? payload.touched.map((f) => `- ${f}`).join('\n') : '- (没有改动任何文件)',
655
- '',
656
- '## 审计(运行前 → 运行后)',
657
- '',
658
- '```',
659
- `运行前:${payload.auditBefore.problems} 个问题`,
660
- `运行后:${payload.auditAfter.problems} 个问题`,
661
- '```',
662
- '',
663
- '### 运行后明细',
664
- '',
665
- '```',
666
- formatAudit(payload.auditAfter, 20),
667
- '```',
668
- '',
669
- '## 模型的整理说明',
670
- '',
671
- payload.finalText || '(模型没有给出文字说明)',
672
- '',
673
- '## 过程',
674
- '',
675
- ...payload.transcript.map(
676
- (t) => `- 第 ${t.step} 轮${t.tools.length ? `:调用 ${t.tools.join('、')}` : ':结束'}${t.text ? ` — ${t.text.slice(0, 120)}` : ''}`,
677
- ),
678
- '',
679
- ]
680
- await writeFile(file, lines.join('\n'), 'utf8')
681
- return { file, name }
682
- }
683
-
684
- /** 列已有报告(设置面板的历史列表)。 */
685
- async listReports(limit = 30) {
686
- const dir = join(this.memoryDir, REPORT_DIR)
687
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
688
- const out = []
689
- for (const e of entries) {
690
- if (!e.isFile() || !e.name.endsWith('.md')) continue
691
- const info = await stat(join(dir, e.name)).catch(() => null)
692
- out.push({ name: e.name, bytes: info?.size ?? 0, at: info?.mtimeMs ?? 0, atHuman: stampHuman(info?.mtimeMs ?? 0) })
693
- }
694
- out.sort((a, b) => b.name.localeCompare(a.name))
695
- return out.slice(0, limit)
696
- }
697
-
698
- /** 读一份报告全文(白名单校验:只能是 dream 报告目录里的 .md)。 */
699
- async readReport(name) {
700
- const base = basename(String(name || ''))
701
- if (!/^[\w.-]+\.md$/.test(base)) throw new Error('sage-mem dream: 报告名不合法')
702
- const file = join(this.memoryDir, REPORT_DIR, base)
703
- const content = await readFile(file, 'utf8')
704
- return { name: base, content }
705
- }
706
-
707
- // ────────────────────────────── 主流程 ──────────────────────────────
708
-
709
- /**
710
- * 跑一次 dream。
711
- * @param {{reason?:string, force?:boolean}} opts — force 跳过门控(手动触发时用)
712
- */
713
- async run(opts = {}) {
714
- const reason = opts.reason ?? '手动触发'
715
- if (this.running) return { ok: false, error: '已有一趟 dream 在跑' }
716
-
717
- await this.load()
718
- const cfg = this.state.config
719
- const startedAt = Date.now()
720
-
721
- let hoursSince = null
722
- let sessionCount = 0
723
- if (!opts.force) {
724
- const g = await this.gate()
725
- if (!g.ok) return { ok: false, error: `门控未通过:${g.reason}` }
726
- hoursSince = Number.isFinite(g.hoursSince) ? g.hoursSince : null
727
- sessionCount = g.sessionCount ?? 0
728
- } else {
729
- sessionCount = await countSessionsSince(this.sessionsRoot, this.state.lastRunAt || 0)
730
- hoursSince = this.state.lastRunAt ? (startedAt - this.state.lastRunAt) / 3600000 : null
731
- }
732
-
733
- if (!(await this.acquireLock())) return { ok: false, error: '拿不到锁(另一趟 dream 正在跑?)' }
734
-
735
- this.running = true
736
- this.startedAt = startedAt
737
- this.stepLog = []
738
- this.phase = '准备中'
739
-
740
- const route = this.resolveRouteSafe(cfg)
741
- let snapshot = null
742
- let auditBefore = null
743
- try {
744
- if (!route) throw new Error('解析不出模型路线:配置里没写 provider/model,也拿不到默认模型')
745
-
746
- if (cfg.apply) {
747
- this.phase = '运行前快照'
748
- snapshot = await this.snapshot()
749
- }
750
-
751
- this.phase = '运行前审计'
752
- auditBefore = await auditMemoryDir(this.memoryDir)
753
-
754
- const withSessions = cfg.source === 'memory+sessions'
755
- const runner = createToolRunner({
756
- memoryDir: this.memoryDir,
757
- sessionsRoot: this.sessionsRoot,
758
- apply: !!cfg.apply,
759
- withSessions,
760
- })
761
- const toolSchemas = dreamToolSchemas({ apply: !!cfg.apply, withSessions })
762
-
763
- const result = await this.agentLoop(cfg, {
764
- provider: route.provider,
765
- model: route.model,
766
- toolSchemas,
767
- runner,
768
- maxSteps: cfg.maxSteps,
769
- tokenBudget: DEFAULT_TOKEN_BUDGET,
770
- hoursSince: hoursSince ?? 0,
771
- sessionCount,
772
- })
773
-
774
- this.phase = '运行后审计'
775
- const auditAfter = await auditMemoryDir(this.memoryDir)
776
-
777
- const report = await this.writeReport({
778
- startedAt,
779
- endedAt: Date.now(),
780
- reason,
781
- apply: !!cfg.apply,
782
- source: cfg.source,
783
- provider: route.provider,
784
- model: route.model,
785
- hoursSince,
786
- sessionCount,
787
- snapshot,
788
- touched: result.touched,
789
- auditBefore,
790
- auditAfter,
791
- finalText: result.finalText,
792
- transcript: result.transcript,
793
- tokensIn: result.tokensIn,
794
- tokensOut: result.tokensOut,
795
- })
796
-
797
- this.state.lastRunAt = Date.now()
798
- this.state.retryAfter = 0
799
- this.state.lastResult = {
800
- at: this.state.lastRunAt,
801
- atHuman: stampHuman(this.state.lastRunAt),
802
- ok: true,
803
- apply: !!cfg.apply,
804
- touched: result.touched,
805
- problemsBefore: auditBefore.problems,
806
- problemsAfter: auditAfter.problems,
807
- tokensIn: result.tokensIn,
808
- tokensOut: result.tokensOut,
809
- report: report.name,
810
- }
811
- await this.persist()
812
- this.phase = ''
813
- return { ok: true, report: report.name, touched: result.touched, auditBefore, auditAfter }
814
- } catch (err) {
815
- const message = err?.message ?? String(err)
816
- this.log('warn', `运行失败:${message}`)
817
- this.state.retryAfter = Date.now() + FAILURE_BACKOFF_MS
818
- this.state.lastResult = {
819
- at: Date.now(),
820
- atHuman: stampHuman(Date.now()),
821
- ok: false,
822
- apply: !!cfg.apply,
823
- error: message,
824
- tokensIn: 0,
825
- tokensOut: 0,
826
- }
827
- await this.persist().catch(() => {})
828
- this.phase = ''
829
- return { ok: false, error: message }
830
- } finally {
831
- this.running = false
832
- await this.releaseLock()
833
- }
834
- }
835
-
836
- // ────────────────────────────── 调度 ──────────────────────────────
837
-
838
- /**
839
- * 挂上自动触发的定时器。
840
- *
841
- * 用 `ctx.timer`(Cordis 混合进 context 的定时器助手)而不是裸 setInterval:
842
- * 它的 dispose 会跟着插件生命周期走,插件卸载时定时器一起消失。
843
- * timer 服务拿不到时退回全局 setInterval,并在日志里说清楚。
844
- */
845
- start() {
846
- const timer = this.svc('timer')
847
- const tick = () => {
848
- this.gate()
849
- .then((g) => {
850
- if (!g.ok) return
851
- this.log('warn', `门控通过,自动开始整理(${g.reason ?? ''} ${g.hoursSince?.toFixed?.(1) ?? '?'}h / ${g.sessionCount} 会话)`)
852
- return this.run({ reason: '自动(门控通过)' })
853
- })
854
- .catch((err) => this.log('warn', `自动触发失败:${err?.message ?? err}`))
855
- }
856
-
857
- if (timer?.interval) {
858
- this.disposers.push(timer.interval(tick, SCHEDULE_INTERVAL_MS))
859
- } else {
860
- const id = setInterval(tick, SCHEDULE_INTERVAL_MS)
861
- if (typeof id.unref === 'function') id.unref()
862
- this.disposers.push(() => clearInterval(id))
863
- }
864
-
865
- if (timer?.timeout) {
866
- this.disposers.push(timer.timeout(tick, STARTUP_CHECK_DELAY_MS))
867
- } else {
868
- const id = setTimeout(tick, STARTUP_CHECK_DELAY_MS)
869
- if (typeof id.unref === 'function') id.unref()
870
- this.disposers.push(() => clearTimeout(id))
871
- }
872
- }
873
-
874
- /** 插件卸载:停掉所有定时器与锁。 */
875
- dispose() {
876
- for (const d of this.disposers) {
877
- try {
878
- d()
879
- } catch {
880
- /* 忽略:卸载路径上的异常不该再冒出来 */
881
- }
882
- }
883
- this.disposers = []
884
- this.releaseLock().catch(() => {})
885
- }
886
- }