sage-mem 0.7.2 → 0.9.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/LICENSE +202 -202
- package/NOTICE +8 -8
- package/README.md +312 -284
- package/cordis.patch.yml +4 -4
- package/lib/autodream/config.js +83 -0
- package/lib/autodream/engine.js +524 -0
- package/lib/autodream/gate.js +78 -0
- package/lib/autodream/llm-loop.js +248 -0
- package/lib/autodream/report.js +285 -0
- package/lib/autodream/rollback.js +221 -0
- package/lib/autodream/settings.js +289 -0
- package/lib/autodream/snapshot.js +142 -0
- package/lib/autodream/util.js +112 -0
- package/lib/autodream-audit.js +270 -229
- package/lib/autodream-prompt.js +129 -129
- package/lib/autodream-tools.js +497 -498
- package/lib/autodream.js +8 -1722
- package/lib/client.js +4277 -2854
- package/lib/index.js +893 -951
- package/lib/memory/access.js +220 -0
- package/lib/memory/archive.js +91 -0
- package/lib/memory/config.js +151 -0
- package/lib/memory/frontmatter.js +117 -0
- package/lib/memory/naming.js +54 -0
- package/lib/memory/retrieval.js +86 -0
- package/lib/memory/scan.js +204 -0
- package/lib/memory/util.js +37 -0
- package/lib/typert.host.js +747 -461
- package/package.json +7 -2
package/lib/autodream.js
CHANGED
|
@@ -1,1722 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* sage-mem autodream ——
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* 任何一级不过,后面的都不做 —— 顺序就是成本顺序。
|
|
10
|
-
* 2. **执行不走真正的子代理**,直接 `ctx.llm.stream()` 手写一个极简 agent 循环。
|
|
11
|
-
* 换来的是:工具集由我们给(安全边界在 autodream-tools.js)、步数有上限、
|
|
12
|
-
* 不需要 agent 生命周期与 provider 注册,**也不会把 autodream 写进用户的会话记录**。
|
|
13
|
-
* 3. **每一趟可回滚、可交代**。memory 目录不在任何版本控制下、删除没有回收站,
|
|
14
|
-
* 所以「可回滚」必须自己造:运行前全量快照 + 运行后逐文件变更清单(含缘由),
|
|
15
|
-
* 二者用同一个 runId 绑在一起,于是「2026-10-01 22:30 那次整理」既能被读懂,
|
|
16
|
-
* 也能被退回去。审计则让每趟 autodream 自证没改坏。
|
|
17
|
-
* 4. **模型路线可单独指定**。整理是后台批量任务,跟聊天用的模型不必是同一个;
|
|
18
|
-
* 空配置 = 跟随当前默认模型,非空 = 用户显式指定,**指定了就绝不静默回落**。
|
|
19
|
-
*
|
|
20
|
-
* 目录约定(都在 memoryDir 的上一级 `.sage-mem/` 下):
|
|
21
|
-
* - 配置与状态:`.sage-mem/autodream.json`(旧版 `.sage-mem/dream.json` 只读迁移)
|
|
22
|
-
* - 运行记录与声明:`.sage-mem/autodream/runs/<runId>/{manifest.json,declaration.md}`
|
|
23
|
-
* - 回滚点:`.sage-mem/autodream/snapshots/<id>/`(旧版 `.sage-mem/snapshots/` 只读兼容)
|
|
24
|
-
* - 回滚留痕:`.sage-mem/autodream/rollbacks.json`
|
|
25
|
-
* - 人读报告:`memory/autodream/<runId>.md`(旧版 `memory/dream/` 只读兼容)
|
|
26
|
-
*
|
|
27
|
-
* 手改配置文件也方便——它是纯文本,且和人读的东西放在一起。
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { readFile, writeFile, readdir, mkdir, copyFile, rm, stat, open, rename } from 'node:fs/promises'
|
|
31
|
-
import { join, dirname } from 'node:path'
|
|
32
|
-
import { randomUUID, createHash } from 'node:crypto'
|
|
33
|
-
import { auditMemoryDir, formatAudit } from './autodream-audit.js'
|
|
34
|
-
import { autodreamToolSchemas, createToolRunner } from './autodream-tools.js'
|
|
35
|
-
import { buildAutodreamSystemPrompt, buildAutodreamUserPrompt } from './autodream-prompt.js'
|
|
36
|
-
|
|
37
|
-
/** 配置结构版本:将来加字段时用来做迁移。v2 = 改名 autodream + 回滚点 + 声明。 */
|
|
38
|
-
const CONFIG_VERSION = 2
|
|
39
|
-
|
|
40
|
-
/** 默认配置。每一项都能从设置面板改;空 provider/model 表示跟聊天页当前默认模型。 */
|
|
41
|
-
const DEFAULT_CONFIG = {
|
|
42
|
-
enabled: false,
|
|
43
|
-
trigger: 'manual',
|
|
44
|
-
apply: false,
|
|
45
|
-
source: 'memory',
|
|
46
|
-
minHours: 24,
|
|
47
|
-
minSessions: 5,
|
|
48
|
-
provider: '',
|
|
49
|
-
model: '',
|
|
50
|
-
maxSteps: 24,
|
|
51
|
-
maxSnapshotKeep: 5,
|
|
52
|
-
/** 回滚按钮的默认范围:files = 只恢复该次运行动过的文件;all = 整目录恢复到该时点。 */
|
|
53
|
-
rollbackScope: 'files',
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* 会话门的扫描节流(10 分钟)。
|
|
58
|
-
*
|
|
59
|
-
* 为什么需要它:时间门过了、会话门没过时,状态里的 lastRunAt 不会前进,
|
|
60
|
-
* 于是每一次触发都会重新扫一遍整个 sessions 目录。
|
|
61
|
-
* 节流把「看着像没过」和「真的没过」区分开——这中间的成本差异全是白烧的盘 IO。
|
|
62
|
-
*/
|
|
63
|
-
const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000
|
|
64
|
-
|
|
65
|
-
/** 自动模式的检查间隔。门控本身很便宜(有节流),所以这个频率是安全的。 */
|
|
66
|
-
const SCHEDULE_INTERVAL_MS = 30 * 60 * 1000
|
|
67
|
-
|
|
68
|
-
/** 启动后第一次自动检查的延迟——避开 DSH 启动时的那阵忙碌。 */
|
|
69
|
-
const STARTUP_CHECK_DELAY_MS = 90 * 1000
|
|
70
|
-
|
|
71
|
-
/** 锁的失效时间:超过这个时长还挂着的锁,认定是上次进程崩了留下的。 */
|
|
72
|
-
const LOCK_STALE_MS = 2 * 60 * 60 * 1000
|
|
73
|
-
|
|
74
|
-
/** 失败后的退避:别让一个每次都失败的任务把门控刷成高频重试。 */
|
|
75
|
-
const FAILURE_BACKOFF_MS = 60 * 60 * 1000
|
|
76
|
-
|
|
77
|
-
/** 报告目录名(相对 memory 根)。放在子目录里,所以不会被 sage-mem 自己扫成记忆。 */
|
|
78
|
-
const REPORT_DIR = 'autodream'
|
|
79
|
-
|
|
80
|
-
/** 改名前的报告目录。只读兼容:老报告还能在面板里翻到,但不再往里写。 */
|
|
81
|
-
const LEGACY_REPORT_DIR = 'dream'
|
|
82
|
-
|
|
83
|
-
/** 状态根目录名(相对 memory 根的上一层)。 */
|
|
84
|
-
const STATE_DIR = '.sage-mem'
|
|
85
|
-
|
|
86
|
-
/** 本功能在状态根下的私有目录(运行记录、回滚点、锁、留痕)。 */
|
|
87
|
-
const FEATURE_DIR = 'autodream'
|
|
88
|
-
|
|
89
|
-
/** 改名前的状态文件名(`<stateRoot>/dream.json`),只在迁移时读一次。 */
|
|
90
|
-
const LEGACY_STATE_FILE = 'dream.json'
|
|
91
|
-
|
|
92
|
-
/** 改名前的快照根(`<stateRoot>/snapshots/`),只读兼容。 */
|
|
93
|
-
const LEGACY_SNAPSHOT_DIR = 'snapshots'
|
|
94
|
-
|
|
95
|
-
/** 「回滚前保护快照」单独保留的份数——它不该被普通运行的快照轮转挤掉。 */
|
|
96
|
-
const PRE_ROLLBACK_KEEP = 3
|
|
97
|
-
|
|
98
|
-
/** 一次 autodream 允许累计的输入 token 上限(软保护,超了就停)。 */
|
|
99
|
-
const DEFAULT_TOKEN_BUDGET = 600000
|
|
100
|
-
|
|
101
|
-
const clampNum = (v, lo, hi, dflt) => (Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt)
|
|
102
|
-
|
|
103
|
-
/** 本地时间戳,给文件名用。 */
|
|
104
|
-
function stampCompact(ms) {
|
|
105
|
-
const d = new Date(ms)
|
|
106
|
-
const p = (n) => String(n).padStart(2, '0')
|
|
107
|
-
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** 本地时间戳,给人看。 */
|
|
111
|
-
function stampHuman(ms) {
|
|
112
|
-
const d = new Date(ms)
|
|
113
|
-
const p = (n) => String(n).padStart(2, '0')
|
|
114
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* 运行 id:`YYYYMMDD-HHmmss-<4 位>`。
|
|
119
|
-
*
|
|
120
|
-
* 加后缀是因为**同一秒里跑第二趟**在手动连点时真会发生,而 runId 同时是
|
|
121
|
-
* 快照目录名、运行记录目录名和报告文件名——撞一次就是三个地方互相覆盖。
|
|
122
|
-
*/
|
|
123
|
-
function newRunId(ms) {
|
|
124
|
-
return `${stampCompact(ms)}-${randomUUID().replace(/-/g, '').slice(0, 4)}`
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** 文件名/目录名的白名单校验(快照 id、运行 id、报告名都走这里,别让路径穿越进来)。 */
|
|
128
|
-
function safeName(raw) {
|
|
129
|
-
const s = String(raw ?? '').trim()
|
|
130
|
-
if (!s || s.includes('/') || s.includes('\\') || s.includes('..')) return null
|
|
131
|
-
if (!/^[\w.-]+$/.test(s)) return null
|
|
132
|
-
return s
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** 读一个 JSON,读不到/解析失败都返回 null(配置与清单文件损坏不该拖死流程)。 */
|
|
136
|
-
async function readJson(file) {
|
|
137
|
-
try {
|
|
138
|
-
return JSON.parse(await readFile(file, 'utf8'))
|
|
139
|
-
} catch {
|
|
140
|
-
return null
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** 快照里实际躺着哪些 .md(清单不可信时以目录为准)。 */
|
|
145
|
-
async function snapshotEntries(dir) {
|
|
146
|
-
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
147
|
-
return entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md')).map((e) => e.name)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* 精简版 chunk 组装器。
|
|
152
|
-
*
|
|
153
|
-
* 只保留 autodream 用得上的两种块,并**自己实现而不是依赖宿主内部包** ——
|
|
154
|
-
* 依赖漂移时挂的是「整个插件加载不了」这种级别的故障,而这里需要的逻辑只有三十行。
|
|
155
|
-
*
|
|
156
|
-
* 两条必须保证的行为:
|
|
157
|
-
* - delta-only 协议(没有 block-start/block-end)也要能组装
|
|
158
|
-
* - `finish.kind === 'max-tokens'` 时**丢掉 tool-call 块**:被截断的调用参数
|
|
159
|
-
* 是不完整的 JSON,拿去执行等于凭半句话动手
|
|
160
|
-
*/
|
|
161
|
-
class BlockAssembler {
|
|
162
|
-
constructor() {
|
|
163
|
-
this.partials = new Map()
|
|
164
|
-
this.order = []
|
|
165
|
-
this._usage = null
|
|
166
|
-
this._finish = null
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
push(chunk) {
|
|
170
|
-
switch (chunk?.type) {
|
|
171
|
-
case 'block-start': {
|
|
172
|
-
if (!this.partials.has(chunk.index)) {
|
|
173
|
-
this.order.push(chunk.index)
|
|
174
|
-
this.partials.set(chunk.index, { blockType: chunk.blockType, text: '', args: '' })
|
|
175
|
-
}
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
case 'text-delta': {
|
|
179
|
-
const p = this.ensure(chunk.index, 'text')
|
|
180
|
-
if (p.block) return
|
|
181
|
-
p.text += chunk.text
|
|
182
|
-
return
|
|
183
|
-
}
|
|
184
|
-
case 'reasoning-delta':
|
|
185
|
-
return
|
|
186
|
-
case 'tool-call-delta': {
|
|
187
|
-
const p = this.ensure(chunk.index, 'tool-call')
|
|
188
|
-
if (p.block) return
|
|
189
|
-
p.callId = chunk.id
|
|
190
|
-
if (chunk.name) p.callName = chunk.name
|
|
191
|
-
p.args += chunk.argumentsDelta
|
|
192
|
-
return
|
|
193
|
-
}
|
|
194
|
-
case 'block-end': {
|
|
195
|
-
const p = this.ensure(chunk.index, chunk.block?.type ?? 'text')
|
|
196
|
-
if (p.block) return
|
|
197
|
-
p.block = chunk.block
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
case 'usage':
|
|
201
|
-
this._usage = chunk.usage
|
|
202
|
-
return
|
|
203
|
-
case 'finish':
|
|
204
|
-
this._finish = chunk.reason
|
|
205
|
-
return
|
|
206
|
-
default:
|
|
207
|
-
return
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
ensure(index, blockType) {
|
|
212
|
-
let p = this.partials.get(index)
|
|
213
|
-
if (!p) {
|
|
214
|
-
p = { blockType, text: '', args: '' }
|
|
215
|
-
this.partials.set(index, p)
|
|
216
|
-
this.order.push(index)
|
|
217
|
-
}
|
|
218
|
-
return p
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
assemble(p, index) {
|
|
222
|
-
if (p.block) return p.block
|
|
223
|
-
switch (p.blockType) {
|
|
224
|
-
case 'text':
|
|
225
|
-
return { type: 'text', text: p.text }
|
|
226
|
-
case 'reasoning':
|
|
227
|
-
return { type: 'reasoning', text: p.text }
|
|
228
|
-
case 'tool-call':
|
|
229
|
-
return { type: 'tool-call', id: p.callId ?? `call-${index}`, name: p.callName ?? '', arguments: p.args }
|
|
230
|
-
default:
|
|
231
|
-
return null
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
blocks() {
|
|
236
|
-
const all = this.order.map((i) => this.assemble(this.partials.get(i), i)).filter(Boolean)
|
|
237
|
-
if (this.finish.kind === 'max-tokens') return all.filter((b) => b.type !== 'tool-call')
|
|
238
|
-
return all
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
get usage() {
|
|
242
|
-
return this._usage
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
get finish() {
|
|
246
|
-
return this._finish ?? { kind: 'stop' }
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** 造一条 user 消息(字段形状遵循 `ctx.llm.stream()` 的消息协议)。 */
|
|
251
|
-
function userMessage(text) {
|
|
252
|
-
return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'sage-mem-autodream' }, id: randomUUID() }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** 造一条 assistant 消息,把模型这一轮的块原样回灌进历史。 */
|
|
256
|
-
function assistantMessage(blocks, provider, model) {
|
|
257
|
-
return {
|
|
258
|
-
role: 'assistant',
|
|
259
|
-
content: blocks,
|
|
260
|
-
source: { kind: 'model', provider, model },
|
|
261
|
-
id: randomUUID(),
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** 造一条工具结果消息。callId 是把结果接回那次调用的唯一凭据。 */
|
|
266
|
-
function toolMessage(callId, text, isError) {
|
|
267
|
-
const msg = {
|
|
268
|
-
role: 'tool',
|
|
269
|
-
content: [{ type: 'text', text }],
|
|
270
|
-
source: { kind: 'tool', callId },
|
|
271
|
-
toolCallId: callId,
|
|
272
|
-
id: randomUUID(),
|
|
273
|
-
}
|
|
274
|
-
if (isError) msg.isError = true
|
|
275
|
-
return msg
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** 数一下 `$DSH_HOME/sessions` 下 mtime 晚于 sinceMs 的会话(只看文件时间,不解压)。 */
|
|
279
|
-
async function countSessionsSince(root, sinceMs) {
|
|
280
|
-
if (!root) return 0
|
|
281
|
-
let n = 0
|
|
282
|
-
let projects
|
|
283
|
-
try {
|
|
284
|
-
projects = await readdir(root, { withFileTypes: true })
|
|
285
|
-
} catch {
|
|
286
|
-
return 0
|
|
287
|
-
}
|
|
288
|
-
for (const p of projects) {
|
|
289
|
-
if (!p.isDirectory()) continue
|
|
290
|
-
const projPath = join(root, p.name)
|
|
291
|
-
let sessions
|
|
292
|
-
try {
|
|
293
|
-
sessions = await readdir(projPath, { withFileTypes: true })
|
|
294
|
-
} catch {
|
|
295
|
-
continue
|
|
296
|
-
}
|
|
297
|
-
for (const s of sessions) {
|
|
298
|
-
if (!s.isDirectory()) continue
|
|
299
|
-
const file = join(projPath, s.name, 'session.v4.jsonl.zstd')
|
|
300
|
-
try {
|
|
301
|
-
const info = await stat(file)
|
|
302
|
-
if (info.mtimeMs >= sinceMs) n++
|
|
303
|
-
} catch {
|
|
304
|
-
// 目录里没有这个文件:不是每一代格式都叫这个名字,跳过即可。
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
return n
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** 人读的字节差:`12.0KB → 10.0KB(-2.0KB)`。 */
|
|
312
|
-
function formatDelta(before, after) {
|
|
313
|
-
const kb = (n) => `${(n / 1024).toFixed(1)}KB`
|
|
314
|
-
const d = after.bytes - before.bytes
|
|
315
|
-
const sign = d > 0 ? '+' : ''
|
|
316
|
-
return `${kb(before.bytes)} → ${kb(after.bytes)}(${sign}${(d / 1024).toFixed(1)}KB)`
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** 操作类型的中文名(声明表格里给人看)。 */
|
|
320
|
-
const OP_LABEL = { create: '新建', update: '改写', archive: '归档' }
|
|
321
|
-
|
|
322
|
-
export class AutodreamEngine {
|
|
323
|
-
/**
|
|
324
|
-
* @param {object} ctx — 插件上下文(Cordis)
|
|
325
|
-
* @param {{memoryDir:string, sessionsRoot:string, version?:string}} opts
|
|
326
|
-
*/
|
|
327
|
-
constructor(ctx, opts) {
|
|
328
|
-
this.ctx = ctx
|
|
329
|
-
this.memoryDir = opts.memoryDir
|
|
330
|
-
this.sessionsRoot = opts.sessionsRoot
|
|
331
|
-
this.version = opts.version
|
|
332
|
-
/** 使用者自己声明的受保护文件名(`<状态根>/reserved.json`)——注入 prompt,绝不写死。 */
|
|
333
|
-
this.reservedFiles = Array.isArray(opts.reservedFiles) ? opts.reservedFiles : []
|
|
334
|
-
this.stateRoot = join(dirname(this.memoryDir), STATE_DIR)
|
|
335
|
-
this.home = join(this.stateRoot, FEATURE_DIR)
|
|
336
|
-
this.configPath = join(this.stateRoot, 'autodream.json')
|
|
337
|
-
this.legacyConfigPath = join(this.stateRoot, LEGACY_STATE_FILE)
|
|
338
|
-
this.lockPath = join(this.home, 'autodream.lock')
|
|
339
|
-
/** 改名过渡期:旧版进程可能还攥着这把锁。两把互不相识的锁 = 两个进程同时改 memory。 */
|
|
340
|
-
this.legacyLockPath = join(this.stateRoot, 'dream.lock')
|
|
341
|
-
this.snapshotRoot = join(this.home, 'snapshots')
|
|
342
|
-
this.legacySnapshotRoot = join(this.stateRoot, LEGACY_SNAPSHOT_DIR)
|
|
343
|
-
this.runsRoot = join(this.home, 'runs')
|
|
344
|
-
this.rollbacksPath = join(this.home, 'rollbacks.json')
|
|
345
|
-
|
|
346
|
-
this.running = false
|
|
347
|
-
this.phase = ''
|
|
348
|
-
this.startedAt = 0
|
|
349
|
-
this.runId = ''
|
|
350
|
-
this.stepLog = []
|
|
351
|
-
this.lastScanAt = 0
|
|
352
|
-
this.state = { config: { ...DEFAULT_CONFIG }, version: CONFIG_VERSION, lastRunAt: 0, lastResult: null, retryAfter: 0 }
|
|
353
|
-
this.disposers = []
|
|
354
|
-
this._configLoaded = false
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** 服务解析:优先 ctx.get(),回落直接属性(不同 Cordis 版本两种都在用)。 */
|
|
358
|
-
svc(name) {
|
|
359
|
-
const viaGet = typeof this.ctx?.get === 'function' ? this.ctx.get(name) : undefined
|
|
360
|
-
return viaGet ?? this.ctx?.[name]
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
log(level, msg) {
|
|
364
|
-
const line = `sage-mem autodream: ${msg}`
|
|
365
|
-
const logger = this.ctx?.logger
|
|
366
|
-
if (level === 'error' && logger?.error) logger.error(line)
|
|
367
|
-
else if (logger?.warn) logger.warn(line)
|
|
368
|
-
else console.warn(line)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// ────────────────────────────── 配置与状态 ──────────────────────────────
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* 读配置 + 状态(同一个文件)。缺失或损坏都回落到默认值,绝不让它拖死插件加载。
|
|
375
|
-
*
|
|
376
|
-
* **改名迁移**:新文件 `<stateRoot>/autodream.json` 不存在而旧文件 `<stateRoot>/dream.json`
|
|
377
|
-
* 存在时,读旧文件、把配置搬到新文件,**旧文件原样留着不删**(用户手改过的东西,
|
|
378
|
-
* 我们没有替他清理的资格;留着也让人能对照)。迁移只发生一次。
|
|
379
|
-
*/
|
|
380
|
-
async load() {
|
|
381
|
-
if (this._configLoaded) return this.state
|
|
382
|
-
let parsed = await readJson(this.configPath)
|
|
383
|
-
let migratedFrom = ''
|
|
384
|
-
if (!parsed) {
|
|
385
|
-
const legacy = await readJson(this.legacyConfigPath)
|
|
386
|
-
if (legacy && typeof legacy === 'object') {
|
|
387
|
-
parsed = legacy
|
|
388
|
-
migratedFrom = LEGACY_STATE_FILE
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (parsed && typeof parsed === 'object') {
|
|
392
|
-
this.state = {
|
|
393
|
-
version: CONFIG_VERSION,
|
|
394
|
-
config: { ...DEFAULT_CONFIG, ...(parsed.config ?? {}) },
|
|
395
|
-
lastRunAt: Number(parsed.lastRunAt) || 0,
|
|
396
|
-
lastResult: parsed.lastResult ?? null,
|
|
397
|
-
retryAfter: Number(parsed.retryAfter) || 0,
|
|
398
|
-
}
|
|
399
|
-
} else {
|
|
400
|
-
this.state = { version: CONFIG_VERSION, config: { ...DEFAULT_CONFIG }, lastRunAt: 0, lastResult: null, retryAfter: 0 }
|
|
401
|
-
}
|
|
402
|
-
this._configLoaded = true
|
|
403
|
-
if (migratedFrom) {
|
|
404
|
-
this.log('warn', `读到旧版配置 ${this.legacyConfigPath},已迁移到 ${this.configPath}(旧文件保留不动)`)
|
|
405
|
-
await this.persist().catch((err) => this.log('warn', `迁移落盘失败(本次仍按内存里的配置跑):${err?.message ?? err}`))
|
|
406
|
-
}
|
|
407
|
-
return this.state
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** 落盘。写入是原子的(临时文件 + rename),半截文件不会留在盘上。 */
|
|
411
|
-
async persist() {
|
|
412
|
-
await mkdir(this.stateRoot, { recursive: true })
|
|
413
|
-
const tmp = `${this.configPath}.${process.pid}.tmp`
|
|
414
|
-
try {
|
|
415
|
-
await writeFile(tmp, JSON.stringify(this.state, null, 2), 'utf8')
|
|
416
|
-
await rename(tmp, this.configPath)
|
|
417
|
-
} finally {
|
|
418
|
-
// rename 成功后 tmp 已不存在(ENOENT),失败时清掉不留垃圾。
|
|
419
|
-
await rm(tmp, { force: true }).catch(() => {})
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/** 给 UI 的完整配置(含有效模型路线与门控快照)。 */
|
|
424
|
-
async getConfig() {
|
|
425
|
-
await this.load()
|
|
426
|
-
const route = this.resolveRoute(this.state.config)
|
|
427
|
-
return {
|
|
428
|
-
config: { ...this.state.config },
|
|
429
|
-
route,
|
|
430
|
-
lastRunAt: this.state.lastRunAt,
|
|
431
|
-
lastResult: this.state.lastResult,
|
|
432
|
-
paths: { memoryDir: this.memoryDir, configPath: this.configPath, stateRoot: this.stateRoot, home: this.home },
|
|
433
|
-
defaults: { ...DEFAULT_CONFIG },
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** 改配置。逐项做类型与范围收敛——UI 传什么进来都不该写出一份坏配置。 */
|
|
438
|
-
async setConfig(patch) {
|
|
439
|
-
await this.load()
|
|
440
|
-
const p = patch && typeof patch === 'object' ? patch : {}
|
|
441
|
-
const c = { ...this.state.config }
|
|
442
|
-
if (typeof p.enabled === 'boolean') c.enabled = p.enabled
|
|
443
|
-
if (p.trigger === 'auto' || p.trigger === 'manual') c.trigger = p.trigger
|
|
444
|
-
if (typeof p.apply === 'boolean') c.apply = p.apply
|
|
445
|
-
if (p.source === 'memory' || p.source === 'memory+sessions') c.source = p.source
|
|
446
|
-
if (p.rollbackScope === 'files' || p.rollbackScope === 'all') c.rollbackScope = p.rollbackScope
|
|
447
|
-
if (p.minHours !== undefined) c.minHours = clampNum(Number(p.minHours), 1, 720, DEFAULT_CONFIG.minHours)
|
|
448
|
-
if (p.minSessions !== undefined) c.minSessions = Math.round(clampNum(Number(p.minSessions), 1, 200, DEFAULT_CONFIG.minSessions))
|
|
449
|
-
if (p.maxSteps !== undefined) c.maxSteps = Math.round(clampNum(Number(p.maxSteps), 1, 80, DEFAULT_CONFIG.maxSteps))
|
|
450
|
-
if (p.maxSnapshotKeep !== undefined)
|
|
451
|
-
c.maxSnapshotKeep = Math.round(clampNum(Number(p.maxSnapshotKeep), 1, 50, DEFAULT_CONFIG.maxSnapshotKeep))
|
|
452
|
-
if (typeof p.provider === 'string') c.provider = p.provider.trim().slice(0, 120)
|
|
453
|
-
if (typeof p.model === 'string') c.model = p.model.trim().slice(0, 160)
|
|
454
|
-
this.state.config = c
|
|
455
|
-
await this.persist()
|
|
456
|
-
return await this.getConfig()
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** 当前会话默认模型路线(取不到就是 null)。 */
|
|
460
|
-
defaultSelection() {
|
|
461
|
-
try {
|
|
462
|
-
const sel = this.svc('agentDefaultModel')?.currentSelection?.()
|
|
463
|
-
const provider = String(sel?.provider ?? '').trim()
|
|
464
|
-
const model = String(sel?.model ?? '').trim()
|
|
465
|
-
if (!provider || !model) return null
|
|
466
|
-
return { provider, model }
|
|
467
|
-
} catch {
|
|
468
|
-
return null
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* 解析这一趟该用哪条模型路线。
|
|
474
|
-
*
|
|
475
|
-
* 三条语义必须分得清(否则「我明明选了模型它怎么还用别的」这类问题查不出来):
|
|
476
|
-
* - `source:'config'` 用户显式指定 → **指定了就只认它**,解析不出来宁可报错也不回落
|
|
477
|
-
* - `source:'default'` 配置留空 → 用当前会话默认模型
|
|
478
|
-
* - `source:'none'` 两条都拿不到 → `error` 里写清原因
|
|
479
|
-
* 永远返回对象,不抛异常;UI 两种形状都要能读(见 contract)。
|
|
480
|
-
*/
|
|
481
|
-
resolveRoute(config) {
|
|
482
|
-
const p = String(config?.provider ?? '').trim()
|
|
483
|
-
const m = String(config?.model ?? '').trim()
|
|
484
|
-
const explicit = !!(p && m)
|
|
485
|
-
// 只填了一半:这是配置错误,不是「跟随默认」。说清楚,别猜。
|
|
486
|
-
if (!explicit && (p || m)) {
|
|
487
|
-
return {
|
|
488
|
-
provider: p,
|
|
489
|
-
model: m,
|
|
490
|
-
fromDefault: false,
|
|
491
|
-
source: 'none',
|
|
492
|
-
error: `配置里只填了 ${p ? 'provider' : 'model'},必须成对填;两项都清空则跟随当前会话默认模型`,
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
if (explicit) return { provider: p, model: m, fromDefault: false, source: 'config', error: null }
|
|
496
|
-
const def = this.defaultSelection()
|
|
497
|
-
if (def) return { provider: def.provider, model: def.model, fromDefault: true, source: 'default', error: null }
|
|
498
|
-
return {
|
|
499
|
-
provider: '',
|
|
500
|
-
model: '',
|
|
501
|
-
fromDefault: false,
|
|
502
|
-
source: 'none',
|
|
503
|
-
error: '配置里没写 provider/model,也拿不到当前会话默认模型',
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* 可选的模型路线清单。
|
|
509
|
-
*
|
|
510
|
-
* 数据源是宿主的 **`llm` 服务**(`listProviders()` + 逐 provider 的 `listModels()`),
|
|
511
|
-
* 三个「看起来更顺手但会出错」的替代都被排除了:
|
|
512
|
-
* - `agentDefaultModel` 只有当前那一条,不是目录;
|
|
513
|
-
* - 面向子代理的模型选择策略会**按白名单过滤 provider**,拿它当全量目录会漏;
|
|
514
|
-
* - `sessionController.modelCatalog()` 依赖会话控制器挂载,而整理是后台任务。
|
|
515
|
-
*
|
|
516
|
-
* 单个 provider 取模型失败**不拖垮整表**(宿主自己的目录也是这么处理的)——
|
|
517
|
-
* 一条渠道的 key 过期不该让整个下拉框变空。
|
|
518
|
-
*/
|
|
519
|
-
async listModels() {
|
|
520
|
-
const routes = []
|
|
521
|
-
let catalogAvailable = false
|
|
522
|
-
let note = ''
|
|
523
|
-
const llm = this.svc('llm')
|
|
524
|
-
if (!llm || typeof llm.listProviders !== 'function') {
|
|
525
|
-
note = 'llm 服务不可用,取不到模型目录,请手填 provider 与 model'
|
|
526
|
-
} else {
|
|
527
|
-
let providers = []
|
|
528
|
-
try {
|
|
529
|
-
providers = llm.listProviders() ?? []
|
|
530
|
-
} catch (err) {
|
|
531
|
-
note = `读取 provider 列表失败:${err?.message ?? err}`
|
|
532
|
-
}
|
|
533
|
-
for (const p of providers) {
|
|
534
|
-
const pid = String(p?.id ?? '').trim()
|
|
535
|
-
if (!pid) continue
|
|
536
|
-
const pName = String(p?.name ?? pid).trim()
|
|
537
|
-
let models = []
|
|
538
|
-
try {
|
|
539
|
-
// 同步返回数组或 Promise 都收(不同宿主版本两种都有)。
|
|
540
|
-
models = (await llm.listModels(pid)) ?? []
|
|
541
|
-
} catch {
|
|
542
|
-
// 单个 provider 挂了不记 note:那是常态,不是整表故障。
|
|
543
|
-
models = []
|
|
544
|
-
}
|
|
545
|
-
for (const m of models) {
|
|
546
|
-
const mid = String(m?.id ?? '').trim()
|
|
547
|
-
if (!mid) continue
|
|
548
|
-
const mName = String(m?.name ?? mid).trim()
|
|
549
|
-
routes.push({ provider: pid, model: mid, label: `${pName} / ${mName}`, isDefault: false })
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
catalogAvailable = routes.length > 0
|
|
553
|
-
}
|
|
554
|
-
const def = this.defaultSelection()
|
|
555
|
-
if (def) {
|
|
556
|
-
const hit = routes.find((r) => r.provider === def.provider && r.model === def.model)
|
|
557
|
-
if (hit) hit.isDefault = true
|
|
558
|
-
else {
|
|
559
|
-
// 当前默认模型可能来自一个 listModels 拿不到东西的 provider(见下面「空目录放行」)。
|
|
560
|
-
// 把它补进来,否则下拉框会显示「跟随默认」但目录里找不到它。
|
|
561
|
-
routes.unshift({
|
|
562
|
-
provider: def.provider,
|
|
563
|
-
model: def.model,
|
|
564
|
-
label: `${def.provider} / ${def.model}`,
|
|
565
|
-
isDefault: true,
|
|
566
|
-
})
|
|
567
|
-
catalogAvailable = true
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
if (!catalogAvailable && !note) note = '宿主没有可枚举的模型目录,请手填 provider 与 model'
|
|
571
|
-
routes.sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`))
|
|
572
|
-
return { routes, defaultRoute: def, catalogAvailable, note }
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* 运行前判「这条路线在宿主里存在吗」。
|
|
577
|
-
*
|
|
578
|
-
* 运行前先做一次**纯本地的存在性校验**(只比对宿主已注册的 provider / model 目录,
|
|
579
|
-
* 不发任何模型请求)—— 这正是「运行前就报错,别跑到一半才失败」要的东西。
|
|
580
|
-
*
|
|
581
|
-
* 两个已知陷阱,都刻意避开:
|
|
582
|
-
* - **空目录放行**:某些适配器的 `listModels` 恒返回 `[]`。
|
|
583
|
-
* 若把「目录为空」当「模型不存在」,会把明明能用的路线毙掉 → 返回 `ok:true, checked:false`。
|
|
584
|
-
* - **不做「查模型元数据」式的存在性判断**:有的适配器对未知模型不报错,
|
|
585
|
-
* 而是合成一份假元数据(`name === id`),拿它校验等于永远通过。
|
|
586
|
-
*
|
|
587
|
-
* @returns {Promise<{ok:boolean, checked:boolean, reason:string}>}
|
|
588
|
-
*/
|
|
589
|
-
async checkRouteAvailable(provider, model) {
|
|
590
|
-
const llm = this.svc('llm')
|
|
591
|
-
if (!llm || typeof llm.listProviders !== 'function') {
|
|
592
|
-
return { ok: true, checked: false, reason: 'llm 服务不可用,跳过存在性校验' }
|
|
593
|
-
}
|
|
594
|
-
let providers = []
|
|
595
|
-
try {
|
|
596
|
-
providers = llm.listProviders() ?? []
|
|
597
|
-
} catch (err) {
|
|
598
|
-
return { ok: true, checked: false, reason: `读取 provider 列表失败(${err?.message ?? err}),跳过校验` }
|
|
599
|
-
}
|
|
600
|
-
const ids = providers.map((p) => String(p?.id ?? '')).filter(Boolean)
|
|
601
|
-
if (!ids.includes(provider)) {
|
|
602
|
-
return { ok: false, checked: true, reason: `宿主没有名为 ${provider} 的 provider(已注册:${ids.join('、') || '(无)'})` }
|
|
603
|
-
}
|
|
604
|
-
let models = []
|
|
605
|
-
try {
|
|
606
|
-
models = (await llm.listModels(provider)) ?? []
|
|
607
|
-
} catch (err) {
|
|
608
|
-
return { ok: false, checked: true, reason: `读取 ${provider} 的模型目录失败:${err?.message ?? err}` }
|
|
609
|
-
}
|
|
610
|
-
if (!models.length) {
|
|
611
|
-
return { ok: true, checked: false, reason: `${provider} 的模型目录为空,无法判断该模型是否存在(放行)` }
|
|
612
|
-
}
|
|
613
|
-
if (!models.some((m) => String(m?.id ?? '') === model)) {
|
|
614
|
-
const names = models.map((m) => String(m?.id ?? '')).filter(Boolean)
|
|
615
|
-
return {
|
|
616
|
-
ok: false,
|
|
617
|
-
checked: true,
|
|
618
|
-
reason: `${provider} 的目录里没有模型 ${model}(可选:${names.slice(0, 8).join('、')}${names.length > 8 ? '…' : ''})`,
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
return { ok: true, checked: true, reason: '' }
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/** 当前状态快照,供设置面板轮询。 */
|
|
625
|
-
async status() {
|
|
626
|
-
await this.load()
|
|
627
|
-
const cfg = this.state.config
|
|
628
|
-
const lastAt = this.state.lastRunAt
|
|
629
|
-
const hoursSince = lastAt ? (Date.now() - lastAt) / 3600000 : null
|
|
630
|
-
return {
|
|
631
|
-
running: this.running,
|
|
632
|
-
phase: this.phase,
|
|
633
|
-
startedAt: this.startedAt,
|
|
634
|
-
runId: this.runId,
|
|
635
|
-
steps: this.stepLog,
|
|
636
|
-
config: { ...cfg },
|
|
637
|
-
route: this.resolveRoute(cfg),
|
|
638
|
-
lastRunAt: lastAt,
|
|
639
|
-
hoursSince,
|
|
640
|
-
lastResult: this.state.lastResult,
|
|
641
|
-
retryAfter: this.state.retryAfter,
|
|
642
|
-
gate: cfg.enabled && cfg.trigger === 'auto' ? { minHours: cfg.minHours, minSessions: cfg.minSessions } : null,
|
|
643
|
-
paths: { memoryDir: this.memoryDir, configPath: this.configPath, stateRoot: this.stateRoot, home: this.home },
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// ────────────────────────────── 门控 ──────────────────────────────
|
|
648
|
-
|
|
649
|
-
/**
|
|
650
|
-
* 三级门控(顺序即成本顺序)。
|
|
651
|
-
* @returns {Promise<{ok:boolean, reason?:string, hoursSince?:number, sessionCount?:number}>}
|
|
652
|
-
*/
|
|
653
|
-
async gate() {
|
|
654
|
-
await this.load()
|
|
655
|
-
const cfg = this.state.config
|
|
656
|
-
if (!cfg.enabled) return { ok: false, reason: '未启用' }
|
|
657
|
-
if (cfg.trigger !== 'auto') return { ok: false, reason: '手动模式(不会自动触发)' }
|
|
658
|
-
|
|
659
|
-
const now = Date.now()
|
|
660
|
-
if (this.state.retryAfter && now < this.state.retryAfter) {
|
|
661
|
-
return { ok: false, reason: `上次失败后的退避中(还有 ${Math.ceil((this.state.retryAfter - now) / 60000)} 分钟)` }
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// ① 时间门
|
|
665
|
-
const lastAt = this.state.lastRunAt
|
|
666
|
-
const hoursSince = lastAt ? (now - lastAt) / 3600000 : Number.POSITIVE_INFINITY
|
|
667
|
-
if (hoursSince < cfg.minHours) {
|
|
668
|
-
return { ok: false, reason: `距上次整理 ${hoursSince.toFixed(1)}h < ${cfg.minHours}h` }
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// ② 扫描节流(在真正扫盘之前)
|
|
672
|
-
if (now - this.lastScanAt < SESSION_SCAN_INTERVAL_MS) {
|
|
673
|
-
return { ok: false, reason: `扫描节流中(${Math.round((now - this.lastScanAt) / 1000)}s 前刚扫过)` }
|
|
674
|
-
}
|
|
675
|
-
this.lastScanAt = now
|
|
676
|
-
|
|
677
|
-
// ③ 会话门
|
|
678
|
-
const sessionCount = await countSessionsSince(this.sessionsRoot, lastAt || 0)
|
|
679
|
-
if (sessionCount < cfg.minSessions) {
|
|
680
|
-
return { ok: false, reason: `期间只有 ${sessionCount} 个会话更新 < ${cfg.minSessions}` }
|
|
681
|
-
}
|
|
682
|
-
return { ok: true, hoursSince, sessionCount }
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// ────────────────────────────── 锁 ──────────────────────────────
|
|
686
|
-
|
|
687
|
-
async acquireLock() {
|
|
688
|
-
await mkdir(this.home, { recursive: true })
|
|
689
|
-
// 改名过渡期:旧版进程的 `dream.lock` 我们看不见,但它一样在改 memory。
|
|
690
|
-
// 新鲜(未过期)就先拒绝——正常升级流程会重启进程,这条只兜「新旧并行」那一小段窗口。
|
|
691
|
-
const legacy = await stat(this.legacyLockPath).catch(() => null)
|
|
692
|
-
if (legacy && Date.now() - legacy.mtimeMs <= LOCK_STALE_MS) return false
|
|
693
|
-
try {
|
|
694
|
-
const fh = await open(this.lockPath, 'wx')
|
|
695
|
-
await fh.write(JSON.stringify({ pid: process.pid, at: Date.now(), runId: this.runId }))
|
|
696
|
-
await fh.close()
|
|
697
|
-
return true
|
|
698
|
-
} catch (err) {
|
|
699
|
-
if (err?.code !== 'EEXIST') throw err
|
|
700
|
-
const info = await stat(this.lockPath).catch(() => null)
|
|
701
|
-
if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
702
|
-
// 上次进程崩了留下的锁:清掉重来(不然 autodream 永远回不来)。
|
|
703
|
-
await rm(this.lockPath, { force: true })
|
|
704
|
-
return this.acquireLock()
|
|
705
|
-
}
|
|
706
|
-
return false
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
async releaseLock() {
|
|
711
|
-
await rm(this.lockPath, { force: true }).catch(() => {})
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// ────────────────────────────── 回滚点(快照) ──────────────────────────────
|
|
715
|
-
|
|
716
|
-
/**
|
|
717
|
-
* 把 memory 目录顶层的 markdown 整体快照一份,作为**回滚点**。
|
|
718
|
-
*
|
|
719
|
-
* 只快照顶层 `.md`:`archive/` 子目录是退役区(不需要每次跟着复制),
|
|
720
|
-
* `autodream/` 是报告区(自己就是产物)。
|
|
721
|
-
*
|
|
722
|
-
* 每条都记 sha256:回滚后「是不是真的回到了那一版」靠它判等——只看体积在 markdown 里太容易骗人。
|
|
723
|
-
*
|
|
724
|
-
* @param {string} id — 快照 id(apply 运行就是 runId;保护快照用 `pre-rollback-<时间戳>`)
|
|
725
|
-
* @param {'run'|'pre-rollback'} kind
|
|
726
|
-
* @returns {Promise<{id:string, dir:string, count:number, expected:number, failed:string[], files:Array<{name:string,bytes:number,sha256:string}>}>}
|
|
727
|
-
*/
|
|
728
|
-
async snapshot(id, kind = 'run') {
|
|
729
|
-
const dir = join(this.snapshotRoot, id)
|
|
730
|
-
await mkdir(this.snapshotRoot, { recursive: true })
|
|
731
|
-
// **故意不用 recursive**:快照 id 撞车必须是硬错误,不能静默覆盖一份已有的回滚点。
|
|
732
|
-
// 2026-10-01 实测踩到 —— 同一秒里连着两次回滚,`pre-rollback-<秒级时间戳>` 撞车,
|
|
733
|
-
// 第二次的保护快照把第一次的覆盖成「回滚后」的状态,于是「退回到保护点」等于什么都没退。
|
|
734
|
-
try {
|
|
735
|
-
await mkdir(dir)
|
|
736
|
-
} catch (err) {
|
|
737
|
-
if (err?.code === 'EEXIST') throw new Error(`回滚点 id 撞车:${id} 已存在,拒绝覆盖已有的回滚点`)
|
|
738
|
-
throw err
|
|
739
|
-
}
|
|
740
|
-
const entries = await readdir(this.memoryDir, { withFileTypes: true }).catch(() => [])
|
|
741
|
-
const files = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
742
|
-
const recorded = []
|
|
743
|
-
/** 没存下来的文件。**不是「警告一下就继续」**:调用方要拿它决定是否中止。 */
|
|
744
|
-
const failed = []
|
|
745
|
-
for (const f of files) {
|
|
746
|
-
try {
|
|
747
|
-
const buf = await readFile(join(this.memoryDir, f.name))
|
|
748
|
-
await writeFile(join(dir, f.name), buf)
|
|
749
|
-
recorded.push({ name: f.name, bytes: buf.length, sha256: createHash('sha256').update(buf).digest('hex') })
|
|
750
|
-
} catch (err) {
|
|
751
|
-
failed.push(f.name)
|
|
752
|
-
this.log('warn', `快照 ${f.name} 失败:${err?.message ?? err}`)
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
const manifest = {
|
|
756
|
-
schema: 1,
|
|
757
|
-
id,
|
|
758
|
-
kind,
|
|
759
|
-
runId: kind === 'run' ? id : null,
|
|
760
|
-
createdAt: Date.now(),
|
|
761
|
-
createdAtHuman: stampHuman(Date.now()),
|
|
762
|
-
files: recorded,
|
|
763
|
-
fileCount: recorded.length,
|
|
764
|
-
failed,
|
|
765
|
-
}
|
|
766
|
-
await writeFile(join(dir, 'MANIFEST.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
|
767
|
-
await this.pruneSnapshots()
|
|
768
|
-
return { id, dir, count: recorded.length, expected: files.length, failed, files: recorded }
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* 淘汰旧回滚点。
|
|
773
|
-
*
|
|
774
|
-
* **两类分开数**:普通运行的快照按 `maxSnapshotKeep`;「回滚前保护快照」
|
|
775
|
-
* 另给一个额度(`PRE_ROLLBACK_KEEP`)—— 保护快照的唯一用途就是「刚回滚完发现滚错了」,
|
|
776
|
-
* 被普通运行的快照轮转挤掉等于没有。旧版目录(`<stateRoot>/snapshots/`)只读,不参与淘汰。
|
|
777
|
-
*/
|
|
778
|
-
async pruneSnapshots() {
|
|
779
|
-
const keep = Math.max(1, this.state.config.maxSnapshotKeep || DEFAULT_CONFIG.maxSnapshotKeep)
|
|
780
|
-
const entries = await readdir(this.snapshotRoot, { withFileTypes: true }).catch(() => [])
|
|
781
|
-
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
|
|
782
|
-
const preRollback = dirs.filter((n) => n.startsWith('pre-rollback-'))
|
|
783
|
-
const runs = dirs.filter((n) => !n.startsWith('pre-rollback-'))
|
|
784
|
-
for (const name of runs.slice(0, Math.max(0, runs.length - keep))) {
|
|
785
|
-
await rm(join(this.snapshotRoot, name), { recursive: true, force: true }).catch(() => {})
|
|
786
|
-
}
|
|
787
|
-
for (const name of preRollback.slice(0, Math.max(0, preRollback.length - PRE_ROLLBACK_KEEP))) {
|
|
788
|
-
await rm(join(this.snapshotRoot, name), { recursive: true, force: true }).catch(() => {})
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/** 快照目录的真实位置:先找新版,再找旧版;都没有返回 null。 */
|
|
793
|
-
async resolveSnapshotDir(id) {
|
|
794
|
-
const name = safeName(id)
|
|
795
|
-
if (!name) return null
|
|
796
|
-
for (const root of [this.snapshotRoot, this.legacySnapshotRoot]) {
|
|
797
|
-
const dir = join(root, name)
|
|
798
|
-
const info = await stat(dir).catch(() => null)
|
|
799
|
-
if (info?.isDirectory()) return dir
|
|
800
|
-
}
|
|
801
|
-
return null
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
/**
|
|
805
|
-
* 列出所有回滚点(新版 + 旧版目录都列)。
|
|
806
|
-
*
|
|
807
|
-
* 「可回退到哪一份」是给人看的,所以:旧版快照标 `legacy:true`、保护快照标 `kind`,
|
|
808
|
-
* 让界面能说清「这一份是哪来的」。
|
|
809
|
-
*/
|
|
810
|
-
async listSnapshots() {
|
|
811
|
-
const out = []
|
|
812
|
-
for (const [root, legacy] of [
|
|
813
|
-
[this.snapshotRoot, false],
|
|
814
|
-
[this.legacySnapshotRoot, true],
|
|
815
|
-
]) {
|
|
816
|
-
const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
|
|
817
|
-
for (const e of entries) {
|
|
818
|
-
if (!e.isDirectory()) continue
|
|
819
|
-
const dir = join(root, e.name)
|
|
820
|
-
const manifest = await readJson(join(dir, 'MANIFEST.json'))
|
|
821
|
-
const names = await snapshotEntries(dir)
|
|
822
|
-
out.push({
|
|
823
|
-
name: e.name,
|
|
824
|
-
at: manifest?.createdAt ?? 0,
|
|
825
|
-
atHuman: manifest?.createdAtHuman ?? e.name,
|
|
826
|
-
files: Array.isArray(manifest?.files)
|
|
827
|
-
? manifest.files.length
|
|
828
|
-
: typeof manifest?.files === 'number'
|
|
829
|
-
? manifest.files
|
|
830
|
-
: names.length,
|
|
831
|
-
path: dir,
|
|
832
|
-
runId: manifest?.runId ?? (manifest?.kind === 'run' ? e.name : null),
|
|
833
|
-
kind: manifest?.kind ?? (legacy ? 'legacy' : 'run'),
|
|
834
|
-
legacy,
|
|
835
|
-
})
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
// 新的在前(名字里带时间戳,字典序即时间序)。
|
|
839
|
-
out.sort((a, b) => b.name.localeCompare(a.name))
|
|
840
|
-
return out
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// ────────────────────────────── LLM 循环 ──────────────────────────────
|
|
844
|
-
|
|
845
|
-
/**
|
|
846
|
-
* 跑一次模型调用,返回组装好的块。
|
|
847
|
-
* @returns {Promise<BlockAssembler>}
|
|
848
|
-
*/
|
|
849
|
-
async streamOnce(llm, req) {
|
|
850
|
-
const asm = new BlockAssembler()
|
|
851
|
-
const options = {
|
|
852
|
-
provider: req.provider,
|
|
853
|
-
model: req.model,
|
|
854
|
-
system: req.system,
|
|
855
|
-
messages: req.messages,
|
|
856
|
-
maxTokens: req.maxTokens,
|
|
857
|
-
signal: req.signal,
|
|
858
|
-
purpose: 'session-title', // 标记为非交互式调用,避免被当成主对话轮次记账
|
|
859
|
-
}
|
|
860
|
-
if (req.tools && req.tools.length) options.tools = req.tools
|
|
861
|
-
for await (const chunk of llm.stream(options)) {
|
|
862
|
-
asm.push(chunk)
|
|
863
|
-
}
|
|
864
|
-
return asm
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
/**
|
|
868
|
-
* 极简 agent 循环:模型 → 工具 → 结果回灌 → 再问,直到模型不再要工具或触顶。
|
|
869
|
-
*
|
|
870
|
-
* 每一步都把「这一轮要了什么工具」记进 stepLog,设置面板显示的就是这个。
|
|
871
|
-
*/
|
|
872
|
-
async agentLoop(cfg, runOpts) {
|
|
873
|
-
const { provider, model, toolSchemas, runner, maxSteps, tokenBudget, signal } = runOpts
|
|
874
|
-
const llm = this.svc('llm')
|
|
875
|
-
if (!llm || typeof llm.stream !== 'function') throw new Error('llm 服务不可用,autodream 无法运行')
|
|
876
|
-
|
|
877
|
-
const system = buildAutodreamSystemPrompt({
|
|
878
|
-
apply: cfg.apply,
|
|
879
|
-
withSessions: cfg.source === 'memory+sessions',
|
|
880
|
-
reservedFiles: this.reservedFiles,
|
|
881
|
-
})
|
|
882
|
-
const userText = buildAutodreamUserPrompt({
|
|
883
|
-
memoryDir: this.memoryDir,
|
|
884
|
-
apply: cfg.apply,
|
|
885
|
-
hoursSince: runOpts.hoursSince ?? 0,
|
|
886
|
-
sessionCount: runOpts.sessionCount ?? 0,
|
|
887
|
-
now: Date.now(),
|
|
888
|
-
})
|
|
889
|
-
const messages = [userMessage(userText)]
|
|
890
|
-
|
|
891
|
-
const transcript = []
|
|
892
|
-
let tokensIn = 0
|
|
893
|
-
let tokensOut = 0
|
|
894
|
-
let finalText = ''
|
|
895
|
-
const allToolsUsed = []
|
|
896
|
-
|
|
897
|
-
for (let step = 1; step <= maxSteps; step++) {
|
|
898
|
-
if (signal?.aborted) throw new Error('autodream 被取消')
|
|
899
|
-
// 让工具执行器知道现在是第几轮:模型没自述缘由时,声明要靠这个追溯「哪一步干的」。
|
|
900
|
-
if (typeof runner.setStep === 'function') runner.setStep(step)
|
|
901
|
-
this.phase = `第 ${step} 轮:等待模型`
|
|
902
|
-
const asm = await this.streamOnce(llm, {
|
|
903
|
-
provider,
|
|
904
|
-
model,
|
|
905
|
-
system,
|
|
906
|
-
messages,
|
|
907
|
-
tools: toolSchemas,
|
|
908
|
-
maxTokens: 4096,
|
|
909
|
-
signal,
|
|
910
|
-
})
|
|
911
|
-
const usage = asm.usage
|
|
912
|
-
if (usage) {
|
|
913
|
-
tokensIn += usage.inputTokens ?? 0
|
|
914
|
-
tokensOut += usage.outputTokens ?? 0
|
|
915
|
-
}
|
|
916
|
-
const blocks = asm.blocks()
|
|
917
|
-
const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
|
|
918
|
-
const calls = blocks.filter((b) => b.type === 'tool-call')
|
|
919
|
-
if (text) finalText = text
|
|
920
|
-
transcript.push({ step, text, tools: calls.map((c) => c.name) })
|
|
921
|
-
this.stepLog.push({ step, tools: calls.map((c) => c.name), chars: text.length, at: Date.now() })
|
|
922
|
-
|
|
923
|
-
if (asm.finish.kind === 'error') {
|
|
924
|
-
throw new Error(`模型返回错误:${asm.finish.failure?.message ?? '未知'}`)
|
|
925
|
-
}
|
|
926
|
-
if (!calls.length) break
|
|
927
|
-
|
|
928
|
-
messages.push(assistantMessage(blocks, provider, model))
|
|
929
|
-
for (const call of calls) {
|
|
930
|
-
let args = {}
|
|
931
|
-
try {
|
|
932
|
-
args = call.arguments ? JSON.parse(call.arguments) : {}
|
|
933
|
-
} catch {
|
|
934
|
-
args = {}
|
|
935
|
-
}
|
|
936
|
-
this.phase = `第 ${step} 轮:${call.name}`
|
|
937
|
-
const out = await runner.run(call.name, args)
|
|
938
|
-
allToolsUsed.push(call.name)
|
|
939
|
-
messages.push(toolMessage(call.id, out, /^(错误|工具执行失败)/.test(out)))
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
if (tokensIn + tokensOut > tokenBudget) {
|
|
943
|
-
transcript.push({ step: step + 1, text: `(已达 token 预算 ${tokenBudget},提前收工)`, tools: [] })
|
|
944
|
-
break
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
return {
|
|
949
|
-
transcript,
|
|
950
|
-
finalText,
|
|
951
|
-
tokensIn,
|
|
952
|
-
tokensOut,
|
|
953
|
-
toolsUsed: allToolsUsed,
|
|
954
|
-
touched: runner.touchedFiles(),
|
|
955
|
-
changes: runner.changes(),
|
|
956
|
-
// warnings = 改动**没发生**(被拒);notes = 改动**发生了**但有话要说。两者不可混。
|
|
957
|
-
warnings: runner.warnings(),
|
|
958
|
-
notes: typeof runner.notes === 'function' ? runner.notes() : [],
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
// ────────────────────────────── 声明(整理交代) ──────────────────────────────
|
|
963
|
-
|
|
964
|
-
/**
|
|
965
|
-
* 把「改了什么、为什么」写成两种形态,落在 `<home>/runs/<runId>/` 下:
|
|
966
|
-
* - `manifest.json` 机读(回滚也从它读「这次动过哪几个文件」)
|
|
967
|
-
* - `declaration.md` 人读(报告里内嵌同一份)
|
|
968
|
-
*
|
|
969
|
-
* 没有改动时也照样写:**「这趟什么都没改」本身就是要交代的信息**。
|
|
970
|
-
*/
|
|
971
|
-
async writeDeclaration(payload) {
|
|
972
|
-
const dir = join(this.runsRoot, payload.runId)
|
|
973
|
-
await mkdir(dir, { recursive: true })
|
|
974
|
-
const manifest = {
|
|
975
|
-
schema: 1,
|
|
976
|
-
runId: payload.runId,
|
|
977
|
-
startedAt: payload.startedAt,
|
|
978
|
-
startedAtHuman: stampHuman(payload.startedAt),
|
|
979
|
-
endedAt: payload.endedAt,
|
|
980
|
-
durationMs: payload.endedAt - payload.startedAt,
|
|
981
|
-
reason: payload.reason,
|
|
982
|
-
mode: payload.apply ? 'apply' : 'report',
|
|
983
|
-
source: payload.source,
|
|
984
|
-
route: { provider: payload.provider, model: payload.model, fromDefault: !!payload.fromDefault },
|
|
985
|
-
gate: { hoursSince: payload.hoursSince, sessionCount: payload.sessionCount },
|
|
986
|
-
tokens: { in: payload.tokensIn, out: payload.tokensOut },
|
|
987
|
-
snapshot: payload.snapshot ? { id: payload.snapshot.id, dir: payload.snapshot.dir, files: payload.snapshot.count } : null,
|
|
988
|
-
audit: {
|
|
989
|
-
before: { problems: payload.auditBefore?.problems ?? 0 },
|
|
990
|
-
after: { problems: payload.auditAfter?.problems ?? 0 },
|
|
991
|
-
},
|
|
992
|
-
changes: payload.changes,
|
|
993
|
-
warnings: payload.warnings,
|
|
994
|
-
notes: payload.notes ?? [],
|
|
995
|
-
report: payload.report ?? null,
|
|
996
|
-
finalText: payload.finalText ?? '',
|
|
997
|
-
rolledBackAt: 0,
|
|
998
|
-
rolledBackAtHuman: '',
|
|
999
|
-
}
|
|
1000
|
-
const markdown = this.renderDeclaration(manifest)
|
|
1001
|
-
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
|
1002
|
-
await writeFile(join(dir, 'declaration.md'), markdown, 'utf8')
|
|
1003
|
-
return { dir, manifest, markdown }
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
/** 渲染人读声明(报告里内嵌,所以标题层级可以从外面压)。 */
|
|
1007
|
-
renderDeclaration(m, headingLevel = 1) {
|
|
1008
|
-
const H = '#'.repeat(Math.max(1, Math.min(6, headingLevel)))
|
|
1009
|
-
const lines = [
|
|
1010
|
-
`${H} 整理声明 · ${m.startedAtHuman}`,
|
|
1011
|
-
'',
|
|
1012
|
-
`- 运行:\`${m.runId}\` · 模式:${m.mode === 'apply' ? '直接改写' : '只出报告'} · 模型:\`${m.route.provider}/${m.route.model}\``,
|
|
1013
|
-
m.snapshot
|
|
1014
|
-
? `- 回滚点:\`${m.snapshot.id}\`(${m.snapshot.files} 个文件)`
|
|
1015
|
-
: '- 回滚点:未做(只出报告模式不写盘,无需回滚)',
|
|
1016
|
-
`- 审计:结构问题 ${m.audit.before.problems} → ${m.audit.after.problems}`,
|
|
1017
|
-
'',
|
|
1018
|
-
`${H}# 改了什么、为什么`,
|
|
1019
|
-
'',
|
|
1020
|
-
]
|
|
1021
|
-
if (!m.changes.length) {
|
|
1022
|
-
lines.push(m.mode === 'apply' ? '(本次没有改动任何文件)' : '(只出报告模式,未改动文件)', '')
|
|
1023
|
-
} else {
|
|
1024
|
-
lines.push('| # | 文件 | 操作 | 缘由 | 变化 |', '|---|---|---|---|---|')
|
|
1025
|
-
let autoCount = 0
|
|
1026
|
-
for (const c of m.changes) {
|
|
1027
|
-
const op = OP_LABEL[c.op] ?? c.op
|
|
1028
|
-
const delta =
|
|
1029
|
-
c.op === 'archive' ? `${(c.before.bytes / 1024).toFixed(1)}KB → archive/` : formatDelta(c.before, c.after)
|
|
1030
|
-
let reason
|
|
1031
|
-
if (c.reasonSource === 'auto' || !String(c.reason ?? '').trim()) {
|
|
1032
|
-
// 模型没自述缘由 → 用宿主追溯得到的信息顶替,并**显式标出来**。
|
|
1033
|
-
// 这条路径存在的理由:硬拒会丢掉一次本来正确的修正,那比缺一句解释更糟。
|
|
1034
|
-
autoCount += 1
|
|
1035
|
-
reason = `⚠(未自述)${c.step ? `第 ${c.step} 轮` : '轮次未知'} · ${op} · ${delta}`
|
|
1036
|
-
} else {
|
|
1037
|
-
// 缘由里的竖线会撕坏表格;换行也会——一并压平。
|
|
1038
|
-
reason = String(c.reason)
|
|
1039
|
-
.replace(/\|/g, '/')
|
|
1040
|
-
.replace(/\s*\n\s*/g, ' ')
|
|
1041
|
-
}
|
|
1042
|
-
lines.push(`| ${c.seq} | \`${c.file}\` | ${op} | ${reason} | ${delta} |`)
|
|
1043
|
-
}
|
|
1044
|
-
lines.push('')
|
|
1045
|
-
if (autoCount) {
|
|
1046
|
-
lines.push(
|
|
1047
|
-
`> 其中 ${autoCount} 条的缘由是**宿主追溯**的(模型没有自述)。改动已经生效,` +
|
|
1048
|
-
'但动机未经模型确认 —— 值得人工看一眼这几条。',
|
|
1049
|
-
'',
|
|
1050
|
-
)
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
if (m.warnings?.length) {
|
|
1054
|
-
lines.push(`${H}# 未能落地的改动(这些改动没有发生)`, '')
|
|
1055
|
-
for (const w of m.warnings) lines.push(`- ${w}`)
|
|
1056
|
-
lines.push('')
|
|
1057
|
-
}
|
|
1058
|
-
if (m.notes?.length) {
|
|
1059
|
-
// 与上一节**必须分开**:上一节说「没发生」,这一节说「已经生效,只是有话要说」。
|
|
1060
|
-
// 混在一节里会出现「标题写着未能落地、正文告诉你已生效」这种自相矛盾
|
|
1061
|
-
lines.push(`${H}# 提示(改动已生效,只是有话要说)`, '')
|
|
1062
|
-
for (const n of m.notes) lines.push(`- ${n}`)
|
|
1063
|
-
lines.push('')
|
|
1064
|
-
}
|
|
1065
|
-
lines.push(`${H}# 模型的收尾说明`, '', m.finalText ? m.finalText : '(模型没有给出文字说明)', '')
|
|
1066
|
-
return lines.join('\n')
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
/** 列运行记录(面板的「整理声明」区)。 */
|
|
1070
|
-
async listRuns(limit = 20) {
|
|
1071
|
-
const entries = await readdir(this.runsRoot, { withFileTypes: true }).catch(() => [])
|
|
1072
|
-
const out = []
|
|
1073
|
-
for (const e of entries) {
|
|
1074
|
-
if (!e.isDirectory()) continue
|
|
1075
|
-
const m = await readJson(join(this.runsRoot, e.name, 'manifest.json'))
|
|
1076
|
-
if (!m) continue
|
|
1077
|
-
const snapId = m.snapshot?.id ?? null
|
|
1078
|
-
out.push({
|
|
1079
|
-
runId: m.runId ?? e.name,
|
|
1080
|
-
at: m.startedAt ?? 0,
|
|
1081
|
-
atHuman: m.startedAtHuman ?? e.name,
|
|
1082
|
-
mode: m.mode ?? 'apply',
|
|
1083
|
-
reason: m.reason ?? '',
|
|
1084
|
-
// 只出报告模式下 changes 必为空;面板要能区分「没改」和「还没跑」。
|
|
1085
|
-
changeCount: Array.isArray(m.changes) ? m.changes.length : 0,
|
|
1086
|
-
report: m.report ?? null,
|
|
1087
|
-
snapshotId: snapId,
|
|
1088
|
-
// 回滚点会被 maxSnapshotKeep 轮转淘汰,**运行记录不会**。所以「这一趟还能不能退」
|
|
1089
|
-
// 必须现查一次——否则界面上会给出「可回滚」的错觉,点下去才发现没得退。
|
|
1090
|
-
snapshotAvailable: snapId ? (await this.resolveSnapshotDir(snapId)) !== null : false,
|
|
1091
|
-
rolledBackAt: Number(m.rolledBackAt) || 0,
|
|
1092
|
-
rolledBackAtHuman: m.rolledBackAtHuman ?? '',
|
|
1093
|
-
})
|
|
1094
|
-
}
|
|
1095
|
-
out.sort((a, b) => b.runId.localeCompare(a.runId))
|
|
1096
|
-
const n = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.min(200, Math.round(Number(limit))) : 20
|
|
1097
|
-
return out.slice(0, n)
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
/** 读一份运行记录的完整声明(机读 + 人读)。 */
|
|
1101
|
-
async readDeclaration(runId) {
|
|
1102
|
-
const id = safeName(runId)
|
|
1103
|
-
if (!id) throw new Error('sage-mem autodream: 运行 id 不合法')
|
|
1104
|
-
const dir = join(this.runsRoot, id)
|
|
1105
|
-
const manifest = await readJson(join(dir, 'manifest.json'))
|
|
1106
|
-
if (!manifest) throw new Error(`sage-mem autodream: 没有这份运行记录:${id}`)
|
|
1107
|
-
const markdown = await readFile(join(dir, 'declaration.md'), 'utf8').catch(() => this.renderDeclaration(manifest))
|
|
1108
|
-
return { runId: id, markdown, manifest }
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// ────────────────────────────── 报告 ──────────────────────────────
|
|
1112
|
-
|
|
1113
|
-
/** 写一份报告到 `memory/autodream/<runId>.md`。 */
|
|
1114
|
-
async writeReport(payload) {
|
|
1115
|
-
const dir = join(this.memoryDir, REPORT_DIR)
|
|
1116
|
-
await mkdir(dir, { recursive: true })
|
|
1117
|
-
// 报告名直接用 runId —— 它已经带时间戳,且与运行记录目录同名,两边对得上。
|
|
1118
|
-
const name = `${payload.runId}.md`
|
|
1119
|
-
const file = join(dir, name)
|
|
1120
|
-
|
|
1121
|
-
const lines = [
|
|
1122
|
-
`# Autodream 报告 · ${stampHuman(payload.startedAt)}`,
|
|
1123
|
-
'',
|
|
1124
|
-
`- 运行:\`${payload.runId}\``,
|
|
1125
|
-
`- 触发:${payload.reason}`,
|
|
1126
|
-
`- 模式:${payload.apply ? '改写记忆(已建回滚点)' : '只出报告'}`,
|
|
1127
|
-
`- 输入源:${payload.source === 'memory+sessions' ? '记忆目录 + 会话记录' : '仅记忆目录'}`,
|
|
1128
|
-
`- 模型:\`${payload.provider}/${payload.model}\`${payload.fromDefault ? '(跟随当前会话默认模型)' : '(配置里指定)'}`,
|
|
1129
|
-
`- 门控:距上次 ${payload.hoursSince === null ? '(首次)' : payload.hoursSince.toFixed(1) + 'h'} · 期间 ${payload.sessionCount} 个会话更新`,
|
|
1130
|
-
`- 耗时:${((payload.endedAt - payload.startedAt) / 1000).toFixed(1)}s · token ${payload.tokensIn} in / ${payload.tokensOut} out`,
|
|
1131
|
-
payload.snapshot
|
|
1132
|
-
? `- 回滚点:\`${payload.snapshot.id}\` · ${payload.snapshot.dir}(${payload.snapshot.count} 个文件)`
|
|
1133
|
-
: '- 回滚点:未做(只读模式不写盘)',
|
|
1134
|
-
'',
|
|
1135
|
-
'## 改动的文件',
|
|
1136
|
-
'',
|
|
1137
|
-
payload.touched.length ? payload.touched.map((f) => `- ${f}`).join('\n') : '- (没有改动任何文件)',
|
|
1138
|
-
'',
|
|
1139
|
-
'## 审计(运行前 → 运行后)',
|
|
1140
|
-
'',
|
|
1141
|
-
'```',
|
|
1142
|
-
`运行前:${payload.auditBefore.problems} 个问题`,
|
|
1143
|
-
`运行后:${payload.auditAfter.problems} 个问题`,
|
|
1144
|
-
'```',
|
|
1145
|
-
'',
|
|
1146
|
-
'### 运行后明细',
|
|
1147
|
-
'',
|
|
1148
|
-
'```',
|
|
1149
|
-
formatAudit(payload.auditAfter, 20),
|
|
1150
|
-
'```',
|
|
1151
|
-
'',
|
|
1152
|
-
// 声明内嵌进报告:一个文件就能回答「这趟干了什么、为什么」,不用两头翻。
|
|
1153
|
-
payload.declarationMarkdown,
|
|
1154
|
-
'## 过程',
|
|
1155
|
-
'',
|
|
1156
|
-
...payload.transcript.map(
|
|
1157
|
-
(t) =>
|
|
1158
|
-
`- 第 ${t.step} 轮${t.tools.length ? `:调用 ${t.tools.join('、')}` : ':结束'}${t.text ? ` — ${t.text.slice(0, 120)}` : ''}`,
|
|
1159
|
-
),
|
|
1160
|
-
'',
|
|
1161
|
-
]
|
|
1162
|
-
await writeFile(file, lines.join('\n'), 'utf8')
|
|
1163
|
-
return { file, name }
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
/** 列已有报告(设置面板的历史列表)。新版目录 + 旧版目录都列,旧版标出来。 */
|
|
1167
|
-
async listReports(limit = 30) {
|
|
1168
|
-
const out = []
|
|
1169
|
-
for (const [dirName, legacy] of [
|
|
1170
|
-
[REPORT_DIR, false],
|
|
1171
|
-
[LEGACY_REPORT_DIR, true],
|
|
1172
|
-
]) {
|
|
1173
|
-
const dir = join(this.memoryDir, dirName)
|
|
1174
|
-
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
1175
|
-
for (const e of entries) {
|
|
1176
|
-
if (!e.isFile() || !e.name.endsWith('.md')) continue
|
|
1177
|
-
const info = await stat(join(dir, e.name)).catch(() => null)
|
|
1178
|
-
out.push({
|
|
1179
|
-
name: legacy ? `${dirName}/${e.name}` : e.name,
|
|
1180
|
-
bytes: info?.size ?? 0,
|
|
1181
|
-
at: info?.mtimeMs ?? 0,
|
|
1182
|
-
atHuman: stampHuman(info?.mtimeMs ?? 0),
|
|
1183
|
-
legacy,
|
|
1184
|
-
})
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
out.sort((a, b) => b.name.localeCompare(a.name))
|
|
1188
|
-
return out.slice(0, limit)
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
/** 读一份报告全文(白名单校验:只能是报告目录里的 .md,允许旧版目录前缀)。 */
|
|
1192
|
-
async readReport(name) {
|
|
1193
|
-
let dirName = REPORT_DIR
|
|
1194
|
-
let base = String(name || '')
|
|
1195
|
-
if (base.startsWith(`${LEGACY_REPORT_DIR}/`)) {
|
|
1196
|
-
dirName = LEGACY_REPORT_DIR
|
|
1197
|
-
base = base.slice(LEGACY_REPORT_DIR.length + 1)
|
|
1198
|
-
}
|
|
1199
|
-
const safe = safeName(base)
|
|
1200
|
-
if (!safe || !/\.md$/i.test(safe)) throw new Error('sage-mem autodream: 报告名不合法')
|
|
1201
|
-
const content = await readFile(join(this.memoryDir, dirName, safe), 'utf8')
|
|
1202
|
-
return { name: dirName === REPORT_DIR ? safe : `${dirName}/${safe}`, content }
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
// ────────────────────────────── 回滚 ──────────────────────────────
|
|
1206
|
-
|
|
1207
|
-
/** 读回滚留痕(面板也许要显示「最近谁退回过」)。 */
|
|
1208
|
-
async listRollbacks(limit = 20) {
|
|
1209
|
-
const all = await readJson(this.rollbacksPath)
|
|
1210
|
-
const list = Array.isArray(all) ? all : []
|
|
1211
|
-
return list.slice(-limit).reverse()
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/** 追加一条回滚留痕。 */
|
|
1215
|
-
async appendRollback(entry) {
|
|
1216
|
-
const all = await readJson(this.rollbacksPath)
|
|
1217
|
-
const list = Array.isArray(all) ? all : []
|
|
1218
|
-
list.push(entry)
|
|
1219
|
-
await mkdir(this.home, { recursive: true })
|
|
1220
|
-
await writeFile(this.rollbacksPath, JSON.stringify(list.slice(-200), null, 2), 'utf8')
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
/** 把运行记录标成「已被回滚」。 */
|
|
1224
|
-
async markRunRolledBack(runId, at) {
|
|
1225
|
-
const id = safeName(runId)
|
|
1226
|
-
if (!id) return
|
|
1227
|
-
const file = join(this.runsRoot, id, 'manifest.json')
|
|
1228
|
-
const m = await readJson(file)
|
|
1229
|
-
if (!m) return
|
|
1230
|
-
m.rolledBackAt = at
|
|
1231
|
-
m.rolledBackAtHuman = stampHuman(at)
|
|
1232
|
-
await writeFile(file, JSON.stringify(m, null, 2), 'utf8')
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
/**
|
|
1236
|
-
* 把记忆目录退回到某个回滚点。
|
|
1237
|
-
*
|
|
1238
|
-
* 四步顺序不可变(见 contract §5):
|
|
1239
|
-
* ① 保护快照 —— 回滚本身也可能滚错,所以回滚前先把「现在」存下来
|
|
1240
|
-
* ② 恢复 —— `all` 整目录;`files` 只动该次运行动过的文件
|
|
1241
|
-
* ③ 留痕 —— 报告 + `rollbacks.json`
|
|
1242
|
-
* ④ 回写运行记录
|
|
1243
|
-
*
|
|
1244
|
-
* **全程不删文件**:回滚中「不该存在」的文件一律移进 `archive/`(人工可捞回)。
|
|
1245
|
-
* memory 目录没有版本控制也没有回收站,`unlink` 不该出现在这条路径上。
|
|
1246
|
-
*
|
|
1247
|
-
* @param {{snapshotId:string, scope?:'files'|'all'}} opts
|
|
1248
|
-
*/
|
|
1249
|
-
async rollback(opts = {}) {
|
|
1250
|
-
const snapshotId = safeName(opts?.snapshotId)
|
|
1251
|
-
const scope = opts?.scope === 'all' ? 'all' : 'files'
|
|
1252
|
-
if (!snapshotId) return { ok: false, error: '快照 id 不合法' }
|
|
1253
|
-
if (this.running) return { ok: false, error: '正在整理中,等它跑完再回滚' }
|
|
1254
|
-
|
|
1255
|
-
const snapDir = await this.resolveSnapshotDir(snapshotId)
|
|
1256
|
-
if (!snapDir) return { ok: false, error: `找不到回滚点:${snapshotId}` }
|
|
1257
|
-
|
|
1258
|
-
await this.load()
|
|
1259
|
-
if (!(await this.acquireLock())) return { ok: false, error: '拿不到锁(另一趟整理正在跑?)' }
|
|
1260
|
-
const startedAt = Date.now()
|
|
1261
|
-
const stamp = stampCompact(startedAt)
|
|
1262
|
-
try {
|
|
1263
|
-
const manifest = await readJson(join(snapDir, 'MANIFEST.json'))
|
|
1264
|
-
const runId = manifest?.runId ?? null
|
|
1265
|
-
|
|
1266
|
-
// 文件级回滚要知道「这次动过哪几个文件」——那信息在运行记录里,不在快照里。
|
|
1267
|
-
let runManifest = null
|
|
1268
|
-
if (scope === 'files') {
|
|
1269
|
-
if (!runId) return { ok: false, error: '这份回滚点没有绑定运行记录(旧版快照),只能选「整目录恢复」' }
|
|
1270
|
-
runManifest = await readJson(join(this.runsRoot, runId, 'manifest.json'))
|
|
1271
|
-
if (!runManifest) return { ok: false, error: `找不到回滚点 ${snapshotId} 对应的运行记录,只能选「整目录恢复」` }
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
// ① 保护快照
|
|
1275
|
-
this.phase = '回滚前保护快照'
|
|
1276
|
-
// 带随机后缀:秒级时间戳会撞(同一秒内连点两次回滚是真实用法),
|
|
1277
|
-
// 而撞车的后果是**覆盖掉上一次的保护快照**——那等于把「回滚也能退」这条承诺吃掉。
|
|
1278
|
-
const protectionId = `pre-rollback-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 4)}`
|
|
1279
|
-
const protection = await this.snapshot(protectionId, 'pre-rollback')
|
|
1280
|
-
|
|
1281
|
-
// ② 恢复
|
|
1282
|
-
this.phase = '恢复文件'
|
|
1283
|
-
const snapNames = await snapshotEntries(snapDir)
|
|
1284
|
-
const snapSet = new Set(snapNames)
|
|
1285
|
-
const current = (await readdir(this.memoryDir, { withFileTypes: true }).catch(() => []))
|
|
1286
|
-
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
1287
|
-
.map((e) => e.name)
|
|
1288
|
-
|
|
1289
|
-
const restored = []
|
|
1290
|
-
const parked = []
|
|
1291
|
-
const skipped = []
|
|
1292
|
-
const archiveDir = join(this.memoryDir, 'archive')
|
|
1293
|
-
await mkdir(archiveDir, { recursive: true })
|
|
1294
|
-
|
|
1295
|
-
/** 移进 archive/(不删)。同名冲突就跳过并记账,绝不覆盖。 */
|
|
1296
|
-
const park = async (name, why) => {
|
|
1297
|
-
const target = join(archiveDir, name)
|
|
1298
|
-
const clash = await stat(target).catch(() => null)
|
|
1299
|
-
if (clash) {
|
|
1300
|
-
skipped.push(`${name}(本想${why},但 archive/ 里已有同名文件)`)
|
|
1301
|
-
return false
|
|
1302
|
-
}
|
|
1303
|
-
await rename(join(this.memoryDir, name), target)
|
|
1304
|
-
parked.push(`${name}(${why})`)
|
|
1305
|
-
return true
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
if (scope === 'all') {
|
|
1309
|
-
for (const name of snapNames) {
|
|
1310
|
-
try {
|
|
1311
|
-
await copyFile(join(snapDir, name), join(this.memoryDir, name))
|
|
1312
|
-
restored.push(name)
|
|
1313
|
-
} catch (err) {
|
|
1314
|
-
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
// 快照之后新建的文件:整目录回滚要让它退场,但不能删——移进 archive/。
|
|
1318
|
-
for (const name of current) {
|
|
1319
|
-
if (!snapSet.has(name)) await park(name, '快照里没有,整目录回滚需退场')
|
|
1320
|
-
}
|
|
1321
|
-
} else {
|
|
1322
|
-
for (const c of runManifest.changes ?? []) {
|
|
1323
|
-
const name = c.file
|
|
1324
|
-
if (c.op === 'update') {
|
|
1325
|
-
if (snapSet.has(name)) {
|
|
1326
|
-
try {
|
|
1327
|
-
await copyFile(join(snapDir, name), join(this.memoryDir, name))
|
|
1328
|
-
restored.push(name)
|
|
1329
|
-
} catch (err) {
|
|
1330
|
-
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
1331
|
-
}
|
|
1332
|
-
} else {
|
|
1333
|
-
skipped.push(`${name}(回滚点里没有这一版,可能已被别的整理改过)`)
|
|
1334
|
-
}
|
|
1335
|
-
} else if (c.op === 'create') {
|
|
1336
|
-
if (current.includes(name)) await park(name, '本次整理新建,回滚即退场')
|
|
1337
|
-
} else if (c.op === 'archive') {
|
|
1338
|
-
// 归档 = 从顶层移进 archive/;回滚就是把它移回来。
|
|
1339
|
-
const src = join(archiveDir, name)
|
|
1340
|
-
const exists = await stat(src).catch(() => null)
|
|
1341
|
-
const atTop = await stat(join(this.memoryDir, name)).catch(() => null)
|
|
1342
|
-
if (!exists) skipped.push(`${name}(archive/ 里找不到,可能已人工处理过)`)
|
|
1343
|
-
else if (atTop) skipped.push(`${name}(顶层已有同名文件,不覆盖)`)
|
|
1344
|
-
else {
|
|
1345
|
-
await rename(src, join(this.memoryDir, name))
|
|
1346
|
-
restored.push(name)
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
// ③ 留痕
|
|
1353
|
-
this.phase = '写回滚报告'
|
|
1354
|
-
const reportName = `${stamp}-rollback.md`
|
|
1355
|
-
const lines = [
|
|
1356
|
-
`# Autodream 回滚报告 · ${stampHuman(startedAt)}`,
|
|
1357
|
-
'',
|
|
1358
|
-
`- 回滚目标:\`${snapshotId}\`${runId ? `(运行 \`${runId}\`)` : '(旧版快照)'}`,
|
|
1359
|
-
`- 范围:${scope === 'all' ? '整目录恢复到该时点' : '只恢复该次运行动过的文件'}`,
|
|
1360
|
-
`- 回滚前保护快照:\`${protection.id}\`(${protection.count} 个文件)—— 滚错了还能再退回来`,
|
|
1361
|
-
'',
|
|
1362
|
-
'## 写回的文件',
|
|
1363
|
-
'',
|
|
1364
|
-
restored.length ? restored.map((f) => `- \`${f}\``).join('\n') : '- (没有文件被写回)',
|
|
1365
|
-
'',
|
|
1366
|
-
'## 移进 archive/ 的文件(没有删除)',
|
|
1367
|
-
'',
|
|
1368
|
-
parked.length ? parked.map((f) => `- ${f}`).join('\n') : '- (没有)',
|
|
1369
|
-
'',
|
|
1370
|
-
]
|
|
1371
|
-
if (skipped.length) {
|
|
1372
|
-
lines.push('## 跳过', '', ...skipped.map((f) => `- ${f}`), '')
|
|
1373
|
-
}
|
|
1374
|
-
const reportDir = join(this.memoryDir, REPORT_DIR)
|
|
1375
|
-
await mkdir(reportDir, { recursive: true })
|
|
1376
|
-
await writeFile(join(reportDir, reportName), lines.join('\n'), 'utf8')
|
|
1377
|
-
|
|
1378
|
-
const entry = {
|
|
1379
|
-
at: startedAt,
|
|
1380
|
-
atHuman: stampHuman(startedAt),
|
|
1381
|
-
snapshotId,
|
|
1382
|
-
scope,
|
|
1383
|
-
runId,
|
|
1384
|
-
restored: restored.length,
|
|
1385
|
-
parked: parked.length,
|
|
1386
|
-
skipped: skipped.length,
|
|
1387
|
-
protection: protection.id,
|
|
1388
|
-
report: reportName,
|
|
1389
|
-
}
|
|
1390
|
-
await this.appendRollback(entry)
|
|
1391
|
-
// ④ 回写运行记录
|
|
1392
|
-
if (runId) await this.markRunRolledBack(runId, startedAt)
|
|
1393
|
-
|
|
1394
|
-
this.phase = ''
|
|
1395
|
-
return {
|
|
1396
|
-
ok: true,
|
|
1397
|
-
restored: restored.length,
|
|
1398
|
-
parked: parked.length,
|
|
1399
|
-
skipped: skipped.length,
|
|
1400
|
-
protection: protection.id,
|
|
1401
|
-
report: reportName,
|
|
1402
|
-
}
|
|
1403
|
-
} catch (err) {
|
|
1404
|
-
const message = err?.message ?? String(err)
|
|
1405
|
-
this.log('warn', `回滚失败:${message}`)
|
|
1406
|
-
this.phase = ''
|
|
1407
|
-
return { ok: false, error: message }
|
|
1408
|
-
} finally {
|
|
1409
|
-
await this.releaseLock()
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
// ────────────────────────────── 主流程 ──────────────────────────────
|
|
1414
|
-
|
|
1415
|
-
/**
|
|
1416
|
-
* 跑一次 autodream。
|
|
1417
|
-
* @param {{reason?:string, force?:boolean}} opts — force 跳过门控(手动触发时用)
|
|
1418
|
-
*/
|
|
1419
|
-
async run(opts = {}) {
|
|
1420
|
-
const reason = opts.reason ?? '手动触发'
|
|
1421
|
-
if (this.running) return { ok: false, error: '已有一趟 autodream 在跑' }
|
|
1422
|
-
|
|
1423
|
-
await this.load()
|
|
1424
|
-
const cfg = this.state.config
|
|
1425
|
-
const startedAt = Date.now()
|
|
1426
|
-
const runId = newRunId(startedAt)
|
|
1427
|
-
|
|
1428
|
-
let hoursSince = null
|
|
1429
|
-
let sessionCount = 0
|
|
1430
|
-
if (opts.gateResult) {
|
|
1431
|
-
// 自动触发路径:门控在 tick 里**已经跑过一遍**,这里绝不能再跑第二遍 ——
|
|
1432
|
-
// `gate()` 内部有 10 分钟扫描节流,第二次调用必然被刚设下的节流挡回来
|
|
1433
|
-
// (「扫描节流中(0s 前刚扫过)」),于是自动整理一次都跑不成;而 tick 若不查
|
|
1434
|
-
// run 的返回值,连一行错误日志都没有。
|
|
1435
|
-
hoursSince = Number.isFinite(opts.gateResult.hoursSince) ? opts.gateResult.hoursSince : null
|
|
1436
|
-
sessionCount = opts.gateResult.sessionCount ?? 0
|
|
1437
|
-
} else if (!opts.force) {
|
|
1438
|
-
const g = await this.gate()
|
|
1439
|
-
if (!g.ok) return { ok: false, error: `门控未通过:${g.reason}` }
|
|
1440
|
-
hoursSince = Number.isFinite(g.hoursSince) ? g.hoursSince : null
|
|
1441
|
-
sessionCount = g.sessionCount ?? 0
|
|
1442
|
-
} else {
|
|
1443
|
-
sessionCount = await countSessionsSince(this.sessionsRoot, this.state.lastRunAt || 0)
|
|
1444
|
-
hoursSince = this.state.lastRunAt ? (startedAt - this.state.lastRunAt) / 3600000 : null
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
if (!(await this.acquireLock())) return { ok: false, error: '拿不到锁(另一趟 autodream 正在跑?)' }
|
|
1448
|
-
|
|
1449
|
-
this.running = true
|
|
1450
|
-
this.startedAt = startedAt
|
|
1451
|
-
this.runId = runId
|
|
1452
|
-
this.stepLog = []
|
|
1453
|
-
this.phase = '准备中'
|
|
1454
|
-
|
|
1455
|
-
const route = this.resolveRoute(cfg)
|
|
1456
|
-
let snapshot = null
|
|
1457
|
-
let auditBefore = null
|
|
1458
|
-
/** 提到 try 外面:失败路径要拿它问「已经写进去哪几条」。 */
|
|
1459
|
-
let runner = null
|
|
1460
|
-
/**
|
|
1461
|
-
* 还没开始任何模型调用 / 写入 —— 也就是「开工前的确定性失败」(路线不存在、配置半填、
|
|
1462
|
-
* 快照不完整、审计跑不动)。
|
|
1463
|
-
*
|
|
1464
|
-
* 这类失败**不该吃失败退避**:退避是为了别让偶发故障把门控刷成高频重试,而配置错误
|
|
1465
|
-
* 是人去改配置才能好的——用户改完却还要等一小时才生效,是最没道理的一种等待。
|
|
1466
|
-
*/
|
|
1467
|
-
let preflight = true
|
|
1468
|
-
try {
|
|
1469
|
-
// 路线在开工前就判死:跑到一半才发现模型不存在,等于白烧一整轮上下文。
|
|
1470
|
-
if (route.source === 'none') throw new Error(`模型路线不可用:${route.error}`)
|
|
1471
|
-
if (route.source === 'config') {
|
|
1472
|
-
const chk = await this.checkRouteAvailable(route.provider, route.model)
|
|
1473
|
-
if (!chk.ok) {
|
|
1474
|
-
throw new Error(
|
|
1475
|
-
`配置里指定的模型路线不可用:${chk.reason}。` +
|
|
1476
|
-
'请到「自动做梦 → 高级设置 → 模型」重新选一条,或清空两项改为跟随默认模型。',
|
|
1477
|
-
)
|
|
1478
|
-
}
|
|
1479
|
-
if (!chk.checked) this.log('warn', `模型路线存在性未完全校验:${chk.reason}`)
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
if (cfg.apply) {
|
|
1483
|
-
this.phase = '运行前快照(回滚点)'
|
|
1484
|
-
snapshot = await this.snapshot(runId, 'run')
|
|
1485
|
-
// 快照不完整就不许开工。残缺的回滚点比没有回滚点**更危险**——它让人以为
|
|
1486
|
-
// 「退得回去」。在任何写操作之前中止,是这条路径上唯一诚实的做法。
|
|
1487
|
-
if (snapshot.failed.length) {
|
|
1488
|
-
const shown = snapshot.failed.slice(0, 3).join('、')
|
|
1489
|
-
throw new Error(
|
|
1490
|
-
`运行前快照有 ${snapshot.failed.length} 个文件没存下来(${shown}${snapshot.failed.length > 3 ? '…' : ''}),` +
|
|
1491
|
-
'回滚点不完整,本次整理已中止(未改动任何文件)。请检查磁盘空间与文件占用后重试。',
|
|
1492
|
-
)
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
this.phase = '运行前审计'
|
|
1497
|
-
auditBefore = await auditMemoryDir(this.memoryDir)
|
|
1498
|
-
|
|
1499
|
-
const withSessions = cfg.source === 'memory+sessions'
|
|
1500
|
-
runner = createToolRunner({
|
|
1501
|
-
memoryDir: this.memoryDir,
|
|
1502
|
-
sessionsRoot: this.sessionsRoot,
|
|
1503
|
-
apply: !!cfg.apply,
|
|
1504
|
-
withSessions,
|
|
1505
|
-
})
|
|
1506
|
-
const toolSchemas = autodreamToolSchemas({ apply: !!cfg.apply, withSessions })
|
|
1507
|
-
|
|
1508
|
-
// 从这里往下就会真的动模型和磁盘了 —— 之后的失败按「运行期故障」处理(吃退避)。
|
|
1509
|
-
preflight = false
|
|
1510
|
-
this.phase = '运行中'
|
|
1511
|
-
const result = await this.agentLoop(cfg, {
|
|
1512
|
-
provider: route.provider,
|
|
1513
|
-
model: route.model,
|
|
1514
|
-
toolSchemas,
|
|
1515
|
-
runner,
|
|
1516
|
-
maxSteps: cfg.maxSteps,
|
|
1517
|
-
tokenBudget: DEFAULT_TOKEN_BUDGET,
|
|
1518
|
-
hoursSince: hoursSince ?? 0,
|
|
1519
|
-
sessionCount,
|
|
1520
|
-
})
|
|
1521
|
-
|
|
1522
|
-
this.phase = '运行后审计'
|
|
1523
|
-
const auditAfter = await auditMemoryDir(this.memoryDir)
|
|
1524
|
-
|
|
1525
|
-
const endedAt = Date.now()
|
|
1526
|
-
this.phase = '整理声明'
|
|
1527
|
-
const declaration = await this.writeDeclaration({
|
|
1528
|
-
runId,
|
|
1529
|
-
startedAt,
|
|
1530
|
-
endedAt,
|
|
1531
|
-
reason,
|
|
1532
|
-
apply: !!cfg.apply,
|
|
1533
|
-
source: cfg.source,
|
|
1534
|
-
provider: route.provider,
|
|
1535
|
-
model: route.model,
|
|
1536
|
-
fromDefault: route.fromDefault,
|
|
1537
|
-
hoursSince,
|
|
1538
|
-
sessionCount,
|
|
1539
|
-
tokensIn: result.tokensIn,
|
|
1540
|
-
tokensOut: result.tokensOut,
|
|
1541
|
-
snapshot,
|
|
1542
|
-
changes: result.changes,
|
|
1543
|
-
warnings: result.warnings,
|
|
1544
|
-
notes: result.notes,
|
|
1545
|
-
auditBefore,
|
|
1546
|
-
auditAfter,
|
|
1547
|
-
finalText: result.finalText,
|
|
1548
|
-
})
|
|
1549
|
-
|
|
1550
|
-
const report = await this.writeReport({
|
|
1551
|
-
runId,
|
|
1552
|
-
startedAt,
|
|
1553
|
-
endedAt,
|
|
1554
|
-
reason,
|
|
1555
|
-
apply: !!cfg.apply,
|
|
1556
|
-
source: cfg.source,
|
|
1557
|
-
provider: route.provider,
|
|
1558
|
-
model: route.model,
|
|
1559
|
-
fromDefault: route.fromDefault,
|
|
1560
|
-
hoursSince,
|
|
1561
|
-
sessionCount,
|
|
1562
|
-
snapshot,
|
|
1563
|
-
touched: result.touched,
|
|
1564
|
-
auditBefore,
|
|
1565
|
-
auditAfter,
|
|
1566
|
-
finalText: result.finalText,
|
|
1567
|
-
transcript: result.transcript,
|
|
1568
|
-
tokensIn: result.tokensIn,
|
|
1569
|
-
tokensOut: result.tokensOut,
|
|
1570
|
-
declarationMarkdown: declaration.markdown,
|
|
1571
|
-
})
|
|
1572
|
-
|
|
1573
|
-
this.state.lastRunAt = Date.now()
|
|
1574
|
-
this.state.retryAfter = 0
|
|
1575
|
-
this.state.lastResult = {
|
|
1576
|
-
at: this.state.lastRunAt,
|
|
1577
|
-
atHuman: stampHuman(this.state.lastRunAt),
|
|
1578
|
-
ok: true,
|
|
1579
|
-
runId,
|
|
1580
|
-
apply: !!cfg.apply,
|
|
1581
|
-
touched: result.touched,
|
|
1582
|
-
changeCount: result.changes.length,
|
|
1583
|
-
problemsBefore: auditBefore.problems,
|
|
1584
|
-
problemsAfter: auditAfter.problems,
|
|
1585
|
-
tokensIn: result.tokensIn,
|
|
1586
|
-
tokensOut: result.tokensOut,
|
|
1587
|
-
report: report.name,
|
|
1588
|
-
snapshotId: snapshot?.id ?? null,
|
|
1589
|
-
}
|
|
1590
|
-
await this.persist()
|
|
1591
|
-
this.phase = ''
|
|
1592
|
-
return {
|
|
1593
|
-
ok: true,
|
|
1594
|
-
runId,
|
|
1595
|
-
report: report.name,
|
|
1596
|
-
touched: result.touched,
|
|
1597
|
-
changeCount: result.changes.length,
|
|
1598
|
-
snapshotId: snapshot?.id ?? null,
|
|
1599
|
-
auditBefore,
|
|
1600
|
-
auditAfter,
|
|
1601
|
-
}
|
|
1602
|
-
} catch (err) {
|
|
1603
|
-
const message = err?.message ?? String(err)
|
|
1604
|
-
this.log('warn', `运行失败:${message}`)
|
|
1605
|
-
|
|
1606
|
-
// **中途失败也要交代。** 已经落盘的改动不会自己消失,而「有改动、无声明」是最坏的
|
|
1607
|
-
// 一种状态:人既不知道发生了什么,也不知道该不该回滚。回滚点此时仍然有效
|
|
1608
|
-
// (它在任何写操作之前就建好了),声明把「中断前已经改了什么」钉住。
|
|
1609
|
-
const partial = runner ? runner.changes() : []
|
|
1610
|
-
if (partial.length) {
|
|
1611
|
-
try {
|
|
1612
|
-
await this.writeDeclaration({
|
|
1613
|
-
runId,
|
|
1614
|
-
startedAt,
|
|
1615
|
-
endedAt: Date.now(),
|
|
1616
|
-
reason: `${reason}(运行中断)`,
|
|
1617
|
-
apply: !!cfg.apply,
|
|
1618
|
-
source: cfg.source,
|
|
1619
|
-
provider: route.provider,
|
|
1620
|
-
model: route.model,
|
|
1621
|
-
fromDefault: route.fromDefault,
|
|
1622
|
-
hoursSince,
|
|
1623
|
-
sessionCount,
|
|
1624
|
-
tokensIn: 0,
|
|
1625
|
-
tokensOut: 0,
|
|
1626
|
-
snapshot,
|
|
1627
|
-
changes: partial,
|
|
1628
|
-
warnings: [...(runner?.warnings() ?? []), `运行在写完 ${partial.length} 条改动之后中断:${message}`],
|
|
1629
|
-
notes: runner?.notes?.() ?? [],
|
|
1630
|
-
auditBefore,
|
|
1631
|
-
auditAfter: null,
|
|
1632
|
-
finalText: `本次运行中断。以上是中断前**已经落盘**的改动;中断原因:${message}`,
|
|
1633
|
-
})
|
|
1634
|
-
} catch (e2) {
|
|
1635
|
-
// 失败路径上的再失败不能盖住原始错误——只记一行,然后照原样把原始错误报出去。
|
|
1636
|
-
this.log('warn', `中断声明写入失败:${e2?.message ?? e2}`)
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
this.state.retryAfter = preflight ? 0 : Date.now() + FAILURE_BACKOFF_MS
|
|
1641
|
-
this.state.lastResult = {
|
|
1642
|
-
at: Date.now(),
|
|
1643
|
-
atHuman: stampHuman(Date.now()),
|
|
1644
|
-
ok: false,
|
|
1645
|
-
runId,
|
|
1646
|
-
apply: !!cfg.apply,
|
|
1647
|
-
error: message,
|
|
1648
|
-
// preflight 失败会明确标出来:它和「模型跑到一半挂了」是两种病,退避策略也不同。
|
|
1649
|
-
preflight,
|
|
1650
|
-
changeCount: partial.length,
|
|
1651
|
-
snapshotId: snapshot?.id ?? null,
|
|
1652
|
-
tokensIn: 0,
|
|
1653
|
-
tokensOut: 0,
|
|
1654
|
-
}
|
|
1655
|
-
await this.persist().catch(() => {})
|
|
1656
|
-
this.phase = ''
|
|
1657
|
-
return { ok: false, error: message }
|
|
1658
|
-
} finally {
|
|
1659
|
-
this.running = false
|
|
1660
|
-
this.runId = ''
|
|
1661
|
-
await this.releaseLock()
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
// ────────────────────────────── 调度 ──────────────────────────────
|
|
1666
|
-
|
|
1667
|
-
/**
|
|
1668
|
-
* 挂上自动触发的定时器。
|
|
1669
|
-
*
|
|
1670
|
-
* 用 `ctx.timer`(Cordis 混合进 context 的定时器助手)而不是裸 setInterval:
|
|
1671
|
-
* 它的 dispose 会跟着插件生命周期走,插件卸载时定时器一起消失。
|
|
1672
|
-
* timer 服务拿不到时退回全局 setInterval,并在日志里说清楚。
|
|
1673
|
-
*/
|
|
1674
|
-
start() {
|
|
1675
|
-
const timer = this.svc('timer')
|
|
1676
|
-
const tick = () => {
|
|
1677
|
-
this.gate()
|
|
1678
|
-
.then((g) => {
|
|
1679
|
-
if (!g.ok) return undefined
|
|
1680
|
-
this.log('warn', `门控通过,自动开始整理(${g.reason ?? ''} ${g.hoursSince?.toFixed?.(1) ?? '?'}h / ${g.sessionCount} 会话)`)
|
|
1681
|
-
// 门控结果**带进去**,别让 run() 再跑一遍 gate()——gate() 有 10 分钟扫描节流,
|
|
1682
|
-
// 第二遍必然被刚设下的节流挡回(详见 run() 里那段注释)。
|
|
1683
|
-
return this.run({ reason: '自动(门控通过)', gateResult: g })
|
|
1684
|
-
})
|
|
1685
|
-
.then((r) => {
|
|
1686
|
-
// run() 失败是**返回** `{ok:false}` 而不是抛;不看返回值就等于静默失败,
|
|
1687
|
-
// 而「自动整理悄悄不工作」正是这个功能最难查的故障形态。
|
|
1688
|
-
if (r && r.ok === false) this.log('warn', `自动整理没有跑成:${r.error ?? '未知原因'}`)
|
|
1689
|
-
})
|
|
1690
|
-
.catch((err) => this.log('warn', `自动触发失败:${err?.message ?? err}`))
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
if (timer?.interval) {
|
|
1694
|
-
this.disposers.push(timer.interval(tick, SCHEDULE_INTERVAL_MS))
|
|
1695
|
-
} else {
|
|
1696
|
-
const id = setInterval(tick, SCHEDULE_INTERVAL_MS)
|
|
1697
|
-
if (typeof id.unref === 'function') id.unref()
|
|
1698
|
-
this.disposers.push(() => clearInterval(id))
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
if (timer?.timeout) {
|
|
1702
|
-
this.disposers.push(timer.timeout(tick, STARTUP_CHECK_DELAY_MS))
|
|
1703
|
-
} else {
|
|
1704
|
-
const id = setTimeout(tick, STARTUP_CHECK_DELAY_MS)
|
|
1705
|
-
if (typeof id.unref === 'function') id.unref()
|
|
1706
|
-
this.disposers.push(() => clearTimeout(id))
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
/** 插件卸载:停掉所有定时器与锁。 */
|
|
1711
|
-
dispose() {
|
|
1712
|
-
for (const d of this.disposers) {
|
|
1713
|
-
try {
|
|
1714
|
-
d()
|
|
1715
|
-
} catch {
|
|
1716
|
-
/* 忽略:卸载路径上的异常不该再冒出来 */
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
this.disposers = []
|
|
1720
|
-
this.releaseLock().catch(() => {})
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem autodream —— 再导出层。
|
|
3
|
+
*
|
|
4
|
+
* 实现按关注点拆到 `lib/autodream/` 下(引擎本体在 `autodream/engine.js`)。
|
|
5
|
+
* 这一层只为保持外部导入面不变:`import { AutodreamEngine } from './autodream.js'` 照旧可用。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { AutodreamEngine } from './autodream/engine.js'
|