sage-mem 0.7.2 → 0.8.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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * autodream —— 极简 agent 循环:流式块装配、消息构造、单次调用与多步循环。
3
+ *
4
+ * 本文件里的函数都是 `AutodreamEngine` 的方法体:`engine.js` 里保留同名同签名的方法,
5
+ * 用 `xxxMod.name.call(this, …)` 转发进来 —— 所以函数体里的 `this` 就是引擎实例,
6
+ * 与拆分前完全一致,没有引入任何模块级可变状态。
7
+ *
8
+ * 之所以不用真正的子代理:见 engine.js 顶部第 2 条设计决定。
9
+ */
10
+
11
+ import { buildAutodreamSystemPrompt, buildAutodreamUserPrompt } from '../autodream-prompt.js'
12
+ import { randomUUID } from 'node:crypto'
13
+
14
+ export class BlockAssembler {
15
+ constructor() {
16
+ this.partials = new Map()
17
+ this.order = []
18
+ this._usage = null
19
+ this._finish = null
20
+ }
21
+
22
+ push(chunk) {
23
+ switch (chunk?.type) {
24
+ case 'block-start': {
25
+ if (!this.partials.has(chunk.index)) {
26
+ this.order.push(chunk.index)
27
+ this.partials.set(chunk.index, { blockType: chunk.blockType, text: '', args: '' })
28
+ }
29
+ return
30
+ }
31
+ case 'text-delta': {
32
+ const p = this.ensure(chunk.index, 'text')
33
+ if (p.block) return
34
+ p.text += chunk.text
35
+ return
36
+ }
37
+ case 'reasoning-delta':
38
+ return
39
+ case 'tool-call-delta': {
40
+ const p = this.ensure(chunk.index, 'tool-call')
41
+ if (p.block) return
42
+ p.callId = chunk.id
43
+ if (chunk.name) p.callName = chunk.name
44
+ p.args += chunk.argumentsDelta
45
+ return
46
+ }
47
+ case 'block-end': {
48
+ const p = this.ensure(chunk.index, chunk.block?.type ?? 'text')
49
+ if (p.block) return
50
+ p.block = chunk.block
51
+ return
52
+ }
53
+ case 'usage':
54
+ this._usage = chunk.usage
55
+ return
56
+ case 'finish':
57
+ this._finish = chunk.reason
58
+ return
59
+ default:
60
+ return
61
+ }
62
+ }
63
+
64
+ ensure(index, blockType) {
65
+ let p = this.partials.get(index)
66
+ if (!p) {
67
+ p = { blockType, text: '', args: '' }
68
+ this.partials.set(index, p)
69
+ this.order.push(index)
70
+ }
71
+ return p
72
+ }
73
+
74
+ assemble(p, index) {
75
+ if (p.block) return p.block
76
+ switch (p.blockType) {
77
+ case 'text':
78
+ return { type: 'text', text: p.text }
79
+ case 'reasoning':
80
+ return { type: 'reasoning', text: p.text }
81
+ case 'tool-call':
82
+ return { type: 'tool-call', id: p.callId ?? `call-${index}`, name: p.callName ?? '', arguments: p.args }
83
+ default:
84
+ return null
85
+ }
86
+ }
87
+
88
+ blocks() {
89
+ const all = this.order.map((i) => this.assemble(this.partials.get(i), i)).filter(Boolean)
90
+ if (this.finish.kind === 'max-tokens') return all.filter((b) => b.type !== 'tool-call')
91
+ return all
92
+ }
93
+
94
+ get usage() {
95
+ return this._usage
96
+ }
97
+
98
+ get finish() {
99
+ return this._finish ?? { kind: 'stop' }
100
+ }
101
+ }
102
+
103
+ /** 造一条 user 消息(字段形状遵循 `ctx.llm.stream()` 的消息协议)。 */
104
+ export function userMessage(text) {
105
+ return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'sage-mem-autodream' }, id: randomUUID() }
106
+ }
107
+
108
+ /** 造一条 assistant 消息,把模型这一轮的块原样回灌进历史。 */
109
+ export function assistantMessage(blocks, provider, model) {
110
+ return {
111
+ role: 'assistant',
112
+ content: blocks,
113
+ source: { kind: 'model', provider, model },
114
+ id: randomUUID(),
115
+ }
116
+ }
117
+
118
+ /** 造一条工具结果消息。callId 是把结果接回那次调用的唯一凭据。 */
119
+ export function toolMessage(callId, text, isError) {
120
+ const msg = {
121
+ role: 'tool',
122
+ content: [{ type: 'text', text }],
123
+ source: { kind: 'tool', callId },
124
+ toolCallId: callId,
125
+ id: randomUUID(),
126
+ }
127
+ if (isError) msg.isError = true
128
+ return msg
129
+ }
130
+
131
+ /**
132
+ * 跑一次模型调用,返回组装好的块。
133
+ * @returns {Promise<BlockAssembler>}
134
+ */
135
+ export async function streamOnce(llm, req) {
136
+ const asm = new BlockAssembler()
137
+ const options = {
138
+ provider: req.provider,
139
+ model: req.model,
140
+ system: req.system,
141
+ messages: req.messages,
142
+ maxTokens: req.maxTokens,
143
+ signal: req.signal,
144
+ purpose: 'session-title', // 标记为非交互式调用,避免被当成主对话轮次记账
145
+ }
146
+ if (req.tools && req.tools.length) options.tools = req.tools
147
+ for await (const chunk of llm.stream(options)) {
148
+ asm.push(chunk)
149
+ }
150
+ return asm
151
+ }
152
+
153
+ /**
154
+ * 极简 agent 循环:模型 → 工具 → 结果回灌 → 再问,直到模型不再要工具或触顶。
155
+ *
156
+ * 每一步都把「这一轮要了什么工具」记进 stepLog,设置面板显示的就是这个。
157
+ */
158
+ export async function agentLoop(cfg, runOpts) {
159
+ const { provider, model, toolSchemas, runner, maxSteps, tokenBudget, signal } = runOpts
160
+ const llm = this.svc('llm')
161
+ if (!llm || typeof llm.stream !== 'function') throw new Error('llm 服务不可用,autodream 无法运行')
162
+
163
+ const system = buildAutodreamSystemPrompt({
164
+ apply: cfg.apply,
165
+ withSessions: cfg.source === 'memory+sessions',
166
+ reservedFiles: this.reservedFiles,
167
+ })
168
+ const userText = buildAutodreamUserPrompt({
169
+ memoryDir: this.memoryDir,
170
+ apply: cfg.apply,
171
+ hoursSince: runOpts.hoursSince ?? 0,
172
+ sessionCount: runOpts.sessionCount ?? 0,
173
+ now: Date.now(),
174
+ })
175
+ const messages = [userMessage(userText)]
176
+
177
+ const transcript = []
178
+ let tokensIn = 0
179
+ let tokensOut = 0
180
+ let finalText = ''
181
+ const allToolsUsed = []
182
+
183
+ for (let step = 1; step <= maxSteps; step++) {
184
+ if (signal?.aborted) throw new Error('autodream 被取消')
185
+ // 让工具执行器知道现在是第几轮:模型没自述缘由时,声明要靠这个追溯「哪一步干的」。
186
+ if (typeof runner.setStep === 'function') runner.setStep(step)
187
+ this.phase = `第 ${step} 轮:等待模型`
188
+ const asm = await this.streamOnce(llm, {
189
+ provider,
190
+ model,
191
+ system,
192
+ messages,
193
+ tools: toolSchemas,
194
+ maxTokens: 4096,
195
+ signal,
196
+ })
197
+ const usage = asm.usage
198
+ if (usage) {
199
+ tokensIn += usage.inputTokens ?? 0
200
+ tokensOut += usage.outputTokens ?? 0
201
+ }
202
+ const blocks = asm.blocks()
203
+ const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
204
+ const calls = blocks.filter((b) => b.type === 'tool-call')
205
+ if (text) finalText = text
206
+ transcript.push({ step, text, tools: calls.map((c) => c.name) })
207
+ this.stepLog.push({ step, tools: calls.map((c) => c.name), chars: text.length, at: Date.now() })
208
+
209
+ if (asm.finish.kind === 'error') {
210
+ throw new Error(`模型返回错误:${asm.finish.failure?.message ?? '未知'}`)
211
+ }
212
+ if (!calls.length) break
213
+
214
+ messages.push(assistantMessage(blocks, provider, model))
215
+ for (const call of calls) {
216
+ let args = {}
217
+ try {
218
+ args = call.arguments ? JSON.parse(call.arguments) : {}
219
+ } catch {
220
+ args = {}
221
+ }
222
+ this.phase = `第 ${step} 轮:${call.name}`
223
+ const out = await runner.run(call.name, args)
224
+ allToolsUsed.push(call.name)
225
+ messages.push(toolMessage(call.id, out, /^(错误|工具执行失败)/.test(out)))
226
+ }
227
+
228
+ if (tokensIn + tokensOut > tokenBudget) {
229
+ transcript.push({ step: step + 1, text: `(已达 token 预算 ${tokenBudget},提前收工)`, tools: [] })
230
+ break
231
+ }
232
+ }
233
+
234
+ return {
235
+ transcript,
236
+ finalText,
237
+ tokensIn,
238
+ tokensOut,
239
+ toolsUsed: allToolsUsed,
240
+ touched: runner.touchedFiles(),
241
+ changes: runner.changes(),
242
+ // warnings = 改动**没发生**(被拒);notes = 改动**发生了**但有话要说。两者不可混。
243
+ warnings: runner.warnings(),
244
+ notes: typeof runner.notes === 'function' ? runner.notes() : [],
245
+ }
246
+ }
247
+
248
+ // ────────────────────────────── 声明(整理交代) ──────────────────────────────
@@ -0,0 +1,256 @@
1
+ /**
2
+ * autodream —— 整理声明与人读报告(引擎方法体)。
3
+ *
4
+ * 本文件里的函数都是 `AutodreamEngine` 的方法体:`engine.js` 里保留同名同签名的方法,
5
+ * 用 `xxxMod.name.call(this, …)` 转发进来 —— 所以函数体里的 `this` 就是引擎实例,
6
+ * 与拆分前完全一致,没有引入任何模块级可变状态。
7
+ */
8
+
9
+ import { formatAudit } from '../autodream-audit.js'
10
+ import { LEGACY_REPORT_DIR, REPORT_DIR } from './config.js'
11
+ import { OP_LABEL, formatDelta, readJson, safeName, stampHuman } from './util.js'
12
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+
15
+ /**
16
+ * 把「改了什么、为什么」写成两种形态,落在 `<home>/runs/<runId>/` 下:
17
+ * - `manifest.json` 机读(回滚也从它读「这次动过哪几个文件」)
18
+ * - `declaration.md` 人读(报告里内嵌同一份)
19
+ *
20
+ * 没有改动时也照样写:**「这趟什么都没改」本身就是要交代的信息**。
21
+ */
22
+ export async function writeDeclaration(payload) {
23
+ const dir = join(this.runsRoot, payload.runId)
24
+ await mkdir(dir, { recursive: true })
25
+ const manifest = {
26
+ schema: 1,
27
+ runId: payload.runId,
28
+ startedAt: payload.startedAt,
29
+ startedAtHuman: stampHuman(payload.startedAt),
30
+ endedAt: payload.endedAt,
31
+ durationMs: payload.endedAt - payload.startedAt,
32
+ reason: payload.reason,
33
+ mode: payload.apply ? 'apply' : 'report',
34
+ source: payload.source,
35
+ route: { provider: payload.provider, model: payload.model, fromDefault: !!payload.fromDefault },
36
+ gate: { hoursSince: payload.hoursSince, sessionCount: payload.sessionCount },
37
+ tokens: { in: payload.tokensIn, out: payload.tokensOut },
38
+ snapshot: payload.snapshot ? { id: payload.snapshot.id, dir: payload.snapshot.dir, files: payload.snapshot.count } : null,
39
+ audit: {
40
+ before: { problems: payload.auditBefore?.problems ?? 0 },
41
+ after: { problems: payload.auditAfter?.problems ?? 0 },
42
+ },
43
+ changes: payload.changes,
44
+ warnings: payload.warnings,
45
+ notes: payload.notes ?? [],
46
+ report: payload.report ?? null,
47
+ finalText: payload.finalText ?? '',
48
+ rolledBackAt: 0,
49
+ rolledBackAtHuman: '',
50
+ }
51
+ const markdown = this.renderDeclaration(manifest)
52
+ await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
53
+ await writeFile(join(dir, 'declaration.md'), markdown, 'utf8')
54
+ return { dir, manifest, markdown }
55
+ }
56
+
57
+ /** 渲染人读声明(报告里内嵌,所以标题层级可以从外面压)。 */
58
+ export function renderDeclaration(m, headingLevel = 1) {
59
+ const H = '#'.repeat(Math.max(1, Math.min(6, headingLevel)))
60
+ const lines = [
61
+ `${H} 整理声明 · ${m.startedAtHuman}`,
62
+ '',
63
+ `- 运行:\`${m.runId}\` · 模式:${m.mode === 'apply' ? '直接改写' : '只出报告'} · 模型:\`${m.route.provider}/${m.route.model}\``,
64
+ m.snapshot
65
+ ? `- 回滚点:\`${m.snapshot.id}\`(${m.snapshot.files} 个文件)`
66
+ : '- 回滚点:未做(只出报告模式不写盘,无需回滚)',
67
+ `- 审计:结构问题 ${m.audit.before.problems} → ${m.audit.after.problems}`,
68
+ '',
69
+ `${H}# 改了什么、为什么`,
70
+ '',
71
+ ]
72
+ if (!m.changes.length) {
73
+ lines.push(m.mode === 'apply' ? '(本次没有改动任何文件)' : '(只出报告模式,未改动文件)', '')
74
+ } else {
75
+ lines.push('| # | 文件 | 操作 | 缘由 | 变化 |', '|---|---|---|---|---|')
76
+ let autoCount = 0
77
+ for (const c of m.changes) {
78
+ const op = OP_LABEL[c.op] ?? c.op
79
+ const delta =
80
+ c.op === 'archive' ? `${(c.before.bytes / 1024).toFixed(1)}KB → archive/` : formatDelta(c.before, c.after)
81
+ let reason
82
+ if (c.reasonSource === 'auto' || !String(c.reason ?? '').trim()) {
83
+ // 模型没自述缘由 → 用宿主追溯得到的信息顶替,并**显式标出来**。
84
+ // 这条路径存在的理由:硬拒会丢掉一次本来正确的修正,那比缺一句解释更糟。
85
+ autoCount += 1
86
+ reason = `⚠(未自述)${c.step ? `第 ${c.step} 轮` : '轮次未知'} · ${op} · ${delta}`
87
+ } else {
88
+ // 缘由里的竖线会撕坏表格;换行也会——一并压平。
89
+ reason = String(c.reason)
90
+ .replace(/\|/g, '/')
91
+ .replace(/\s*\n\s*/g, ' ')
92
+ }
93
+ lines.push(`| ${c.seq} | \`${c.file}\` | ${op} | ${reason} | ${delta} |`)
94
+ }
95
+ lines.push('')
96
+ if (autoCount) {
97
+ lines.push(
98
+ `> 其中 ${autoCount} 条的缘由是**宿主追溯**的(模型没有自述)。改动已经生效,` +
99
+ '但动机未经模型确认 —— 值得人工看一眼这几条。',
100
+ '',
101
+ )
102
+ }
103
+ }
104
+ if (m.warnings?.length) {
105
+ lines.push(`${H}# 未能落地的改动(这些改动没有发生)`, '')
106
+ for (const w of m.warnings) lines.push(`- ${w}`)
107
+ lines.push('')
108
+ }
109
+ if (m.notes?.length) {
110
+ // 与上一节**必须分开**:上一节说「没发生」,这一节说「已经生效,只是有话要说」。
111
+ // 混在一节里会出现「标题写着未能落地、正文告诉你已生效」这种自相矛盾
112
+ lines.push(`${H}# 提示(改动已生效,只是有话要说)`, '')
113
+ for (const n of m.notes) lines.push(`- ${n}`)
114
+ lines.push('')
115
+ }
116
+ lines.push(`${H}# 模型的收尾说明`, '', m.finalText ? m.finalText : '(模型没有给出文字说明)', '')
117
+ return lines.join('\n')
118
+ }
119
+
120
+ /** 列运行记录(面板的「整理声明」区)。 */
121
+ export async function listRuns(limit = 20) {
122
+ const entries = await readdir(this.runsRoot, { withFileTypes: true }).catch(() => [])
123
+ const out = []
124
+ for (const e of entries) {
125
+ if (!e.isDirectory()) continue
126
+ const m = await readJson(join(this.runsRoot, e.name, 'manifest.json'))
127
+ if (!m) continue
128
+ const snapId = m.snapshot?.id ?? null
129
+ out.push({
130
+ runId: m.runId ?? e.name,
131
+ at: m.startedAt ?? 0,
132
+ atHuman: m.startedAtHuman ?? e.name,
133
+ mode: m.mode ?? 'apply',
134
+ reason: m.reason ?? '',
135
+ // 只出报告模式下 changes 必为空;面板要能区分「没改」和「还没跑」。
136
+ changeCount: Array.isArray(m.changes) ? m.changes.length : 0,
137
+ report: m.report ?? null,
138
+ snapshotId: snapId,
139
+ // 回滚点会被 maxSnapshotKeep 轮转淘汰,**运行记录不会**。所以「这一趟还能不能退」
140
+ // 必须现查一次——否则界面上会给出「可回滚」的错觉,点下去才发现没得退。
141
+ snapshotAvailable: snapId ? (await this.resolveSnapshotDir(snapId)) !== null : false,
142
+ rolledBackAt: Number(m.rolledBackAt) || 0,
143
+ rolledBackAtHuman: m.rolledBackAtHuman ?? '',
144
+ })
145
+ }
146
+ out.sort((a, b) => b.runId.localeCompare(a.runId))
147
+ const n = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.min(200, Math.round(Number(limit))) : 20
148
+ return out.slice(0, n)
149
+ }
150
+
151
+ /** 读一份运行记录的完整声明(机读 + 人读)。 */
152
+ export async function readDeclaration(runId) {
153
+ const id = safeName(runId)
154
+ if (!id) throw new Error('sage-mem autodream: 运行 id 不合法')
155
+ const dir = join(this.runsRoot, id)
156
+ const manifest = await readJson(join(dir, 'manifest.json'))
157
+ if (!manifest) throw new Error(`sage-mem autodream: 没有这份运行记录:${id}`)
158
+ const markdown = await readFile(join(dir, 'declaration.md'), 'utf8').catch(() => this.renderDeclaration(manifest))
159
+ return { runId: id, markdown, manifest }
160
+ }
161
+
162
+ // ────────────────────────────── 报告 ──────────────────────────────
163
+
164
+ /** 写一份报告到 `memory/autodream/<runId>.md`。 */
165
+ export async function writeReport(payload) {
166
+ const dir = join(this.memoryDir, REPORT_DIR)
167
+ await mkdir(dir, { recursive: true })
168
+ // 报告名直接用 runId —— 它已经带时间戳,且与运行记录目录同名,两边对得上。
169
+ const name = `${payload.runId}.md`
170
+ const file = join(dir, name)
171
+
172
+ const lines = [
173
+ `# Autodream 报告 · ${stampHuman(payload.startedAt)}`,
174
+ '',
175
+ `- 运行:\`${payload.runId}\``,
176
+ `- 触发:${payload.reason}`,
177
+ `- 模式:${payload.apply ? '改写记忆(已建回滚点)' : '只出报告'}`,
178
+ `- 输入源:${payload.source === 'memory+sessions' ? '记忆目录 + 会话记录' : '仅记忆目录'}`,
179
+ `- 模型:\`${payload.provider}/${payload.model}\`${payload.fromDefault ? '(跟随当前会话默认模型)' : '(配置里指定)'}`,
180
+ `- 门控:距上次 ${payload.hoursSince === null ? '(首次)' : payload.hoursSince.toFixed(1) + 'h'} · 期间 ${payload.sessionCount} 个会话更新`,
181
+ `- 耗时:${((payload.endedAt - payload.startedAt) / 1000).toFixed(1)}s · token ${payload.tokensIn} in / ${payload.tokensOut} out`,
182
+ payload.snapshot
183
+ ? `- 回滚点:\`${payload.snapshot.id}\` · ${payload.snapshot.dir}(${payload.snapshot.count} 个文件)`
184
+ : '- 回滚点:未做(只读模式不写盘)',
185
+ '',
186
+ '## 改动的文件',
187
+ '',
188
+ payload.touched.length ? payload.touched.map((f) => `- ${f}`).join('\n') : '- (没有改动任何文件)',
189
+ '',
190
+ '## 审计(运行前 → 运行后)',
191
+ '',
192
+ '```',
193
+ `运行前:${payload.auditBefore.problems} 个问题`,
194
+ `运行后:${payload.auditAfter.problems} 个问题`,
195
+ '```',
196
+ '',
197
+ '### 运行后明细',
198
+ '',
199
+ '```',
200
+ formatAudit(payload.auditAfter, 20),
201
+ '```',
202
+ '',
203
+ // 声明内嵌进报告:一个文件就能回答「这趟干了什么、为什么」,不用两头翻。
204
+ payload.declarationMarkdown,
205
+ '## 过程',
206
+ '',
207
+ ...payload.transcript.map(
208
+ (t) =>
209
+ `- 第 ${t.step} 轮${t.tools.length ? `:调用 ${t.tools.join('、')}` : ':结束'}${t.text ? ` — ${t.text.slice(0, 120)}` : ''}`,
210
+ ),
211
+ '',
212
+ ]
213
+ await writeFile(file, lines.join('\n'), 'utf8')
214
+ return { file, name }
215
+ }
216
+
217
+ /** 列已有报告(设置面板的历史列表)。新版目录 + 旧版目录都列,旧版标出来。 */
218
+ export async function listReports(limit = 30) {
219
+ const out = []
220
+ for (const [dirName, legacy] of [
221
+ [REPORT_DIR, false],
222
+ [LEGACY_REPORT_DIR, true],
223
+ ]) {
224
+ const dir = join(this.memoryDir, dirName)
225
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
226
+ for (const e of entries) {
227
+ if (!e.isFile() || !e.name.endsWith('.md')) continue
228
+ const info = await stat(join(dir, e.name)).catch(() => null)
229
+ out.push({
230
+ name: legacy ? `${dirName}/${e.name}` : e.name,
231
+ bytes: info?.size ?? 0,
232
+ at: info?.mtimeMs ?? 0,
233
+ atHuman: stampHuman(info?.mtimeMs ?? 0),
234
+ legacy,
235
+ })
236
+ }
237
+ }
238
+ out.sort((a, b) => b.name.localeCompare(a.name))
239
+ return out.slice(0, limit)
240
+ }
241
+
242
+ /** 读一份报告全文(白名单校验:只能是报告目录里的 .md,允许旧版目录前缀)。 */
243
+ export async function readReport(name) {
244
+ let dirName = REPORT_DIR
245
+ let base = String(name || '')
246
+ if (base.startsWith(`${LEGACY_REPORT_DIR}/`)) {
247
+ dirName = LEGACY_REPORT_DIR
248
+ base = base.slice(LEGACY_REPORT_DIR.length + 1)
249
+ }
250
+ const safe = safeName(base)
251
+ if (!safe || !/\.md$/i.test(safe)) throw new Error('sage-mem autodream: 报告名不合法')
252
+ const content = await readFile(join(this.memoryDir, dirName, safe), 'utf8')
253
+ return { name: dirName === REPORT_DIR ? safe : `${dirName}/${safe}`, content }
254
+ }
255
+
256
+ // ────────────────────────────── 回滚 ──────────────────────────────