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.
@@ -1,498 +1,497 @@
1
- /**
2
- * sage-mem autodream — 交给 autodream 模型的工具集,以及它们的 host 侧执行器。
3
- *
4
- * 这一层是**安全边界**,不是提示词。常见做法是「让模型随便请求、宿主逐个裁决」;
5
- * 这里换了个更硬的写法 —— **模型只能看见这几个工具**,
6
- * 而每个工具的宿主实现只认 memory 目录顶层的 `.md` 文件名。模型想越界
7
- * (写别的目录、动子目录、写非 md、路径穿越)不是「被拒绝」,是**没有这个能力**。
8
- *
9
- * 还有两条刻意的不对称:
10
- * - 「只出报告」模式下,写工具**根本不注册** —— 不是让模型别写,是它没有手。
11
- * - 删除也不是删除:archive_memory 把文件移进 `archive/`,可人工捞回。
12
- * memory 目录不在任何版本控制下、unlink 没有回收站,所以不留真删这条路径。
13
- *
14
- * **每一次写操作都必须留下「改了什么、为什么」**:
15
- * - `write_memory` 的 `reason` **强烈建议**但不强制:缺了照样写入,声明里标成「未自述」
16
- * 并用宿主能追溯到的信息(第几轮 / 操作 / 字节变化)顶替。**硬拒的代价是丢掉一次
17
- * 本来正确的修正**,那比缺一句解释更糟(2026-10-01 由参考设计调研推翻硬拒方案)。
18
- * - `archive_memory` 的 `reason` 仍**必填**:归档是不可逆的退役动作,必须有人能说清为什么。
19
- * - 每次成功写入/归档都产出一条结构化 `change`(前后指纹 + 缘由 + 缘由来源 + 字节差),
20
- * 由 `changes()` 交给上层落成「整理声明」。**回滚也靠它**:只有知道动过哪几个文件、
21
- * 动之前长什么样,才谈得上把文件级回滚做准。
22
- *
23
- * 模型与宿主对同一份数据的理解必须一致:`list_memory` 的字段顺序、
24
- * `write_memory` 的 frontmatter 规范都沿用 sage-mem 的既有约定,不另造一套。
25
- */
26
-
27
- import { readFile, readdir, writeFile, rename, stat, mkdir } from 'node:fs/promises'
28
- import { join, basename } from 'node:path'
29
- import { createHash } from 'node:crypto'
30
- import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
31
-
32
- /** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
33
- const READ_LIMIT = 20000
34
- /** list_memory 里每条描述保留多少字。 */
35
- const LIST_DESC_CHARS = 70
36
- /** search_sessions 单次返回上限(字符)。 */
37
- const SEARCH_LIMIT = 8000
38
- /** 归档区目录名(相对 memory 根)。 */
39
- const ARCHIVE_DIR = 'archive'
40
- /** 索引文件名:它在列表里要单独标注,免得模型当成一条普通记忆去改。 */
41
- const INDEX_FILE = 'memory.md'
42
- /**
43
- * 非记忆条目:不进 list_memory。
44
- *
45
- * session-log.md 是追加型流水,不是一条记忆 —— 列出来既
46
- * 占掉模型一大块注意力,又诱导它去「整理」一份流水账。2026-09-27 冒烟测试
47
- * 逮到的就是这条:它出现在了 list_memory 的输出里。
48
- */
49
- const NON_ENTRY_FILES = new Set(['session-log.md'])
50
-
51
- /**
52
- * 把一个模型给的文件名收敛成 memory 目录顶层的一个真实路径。
53
- *
54
- * 返回 null 表示「这个请求不合法」——调用方一律当成工具执行失败回给模型,
55
- * 不抛异常打断整个 autodream(模型写错一次文件名不该毁掉整趟整理)。
56
- *
57
- * @param {string} memoryDir — 记忆根目录
58
- * @param {unknown} raw — 模型给的 file 参数
59
- * @returns {string|null} 绝对路径,或 null
60
- */
61
- export function safeMemoryPath(memoryDir, raw) {
62
- if (typeof raw !== 'string') return null
63
- const trimmed = raw.trim()
64
- if (!trimmed) return null
65
- // 显式拒绝任何路径分隔与上跳:basename 只做兜底,先拒绝更清楚。
66
- if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) return null
67
- if (!/\.md$/i.test(trimmed)) return null
68
- const base = basename(trimmed)
69
- if (base.startsWith('.')) return null
70
- return join(memoryDir, base)
71
- }
72
-
73
- /**
74
- * 把模型给的文件名解析成**磁盘上的真实名字**。
75
- *
76
- * 为什么必须有这一步:NTFS 不区分大小写 —— 模型写 `A.md` 而盘上是 `a.md` 时,
77
- * 读写命中的是**同一个文件**。但变更记录、快照清单、回滚时用的都是「模型给的那个名字」,
78
- * 于是回滚会去快照里找一个不存在的 `A.md`,**明明有原版却退不回去**,
79
- * 而且留痕还写着「回滚点里没有这一版,可能已被别的整理改过」——理由是错的。
80
- *
81
- * 文件不存在时按原样返回(新建场景)。
82
- */
83
- async function resolveRealName(memoryDir, base) {
84
- const lower = base.toLowerCase()
85
- const entries = await readdir(memoryDir, { withFileTypes: true }).catch(() => [])
86
- const hit = entries.find((e) => e.isFile() && e.name.toLowerCase() === lower)
87
- return hit ? hit.name : base
88
- }
89
-
90
- /** ISO 时间 → 本地 `YYYY-MM-DD HH:mm`(报告与列表里给人看)。 */function localStamp(ms) {
91
- const d = new Date(ms)
92
- const p = (n) => String(n).padStart(2, '0')
93
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
94
- }
95
-
96
- /** 距今多少天,给模型一个人话尺度。 */
97
- function ageDays(ms) {
98
- return Math.round(((Date.now() - ms) / 86400000) * 10) / 10
99
- }
100
-
101
- /**
102
- * 扫记忆目录,产出列表用的轻量元数据(不读全文)。
103
- * @param {string} memoryDir
104
- * @returns {Promise<Array<object>>}
105
- */
106
- async function scanEntries(memoryDir) {
107
- let entries
108
- try {
109
- entries = await readdir(memoryDir, { withFileTypes: true })
110
- } catch (err) {
111
- if (err?.code === 'ENOENT') return []
112
- throw err
113
- }
114
- const names = entries
115
- .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !NON_ENTRY_FILES.has(e.name.toLowerCase()))
116
- .map((e) => e.name)
117
- const out = []
118
- for (const name of names) {
119
- const full = join(memoryDir, name)
120
- try {
121
- const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
122
- const meta = parseFrontmatterLite(content)
123
- const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '')
124
- const h1 = body.match(/^#\s+(.+)$/m)
125
- out.push({
126
- file: name,
127
- // 索引不是一条记忆:标出来,否则模型会看到「MEMORY.md · reference」而困惑。
128
- type: name.toLowerCase() === INDEX_FILE ? '索引' : meta.type || 'reference',
129
- bytes: info.size,
130
- mtimeMs: info.mtimeMs,
131
- title: (h1 ? h1[1] : meta.name || '').trim(),
132
- description: meta.description || '',
133
- })
134
- } catch (err) {
135
- if (err?.code !== 'ENOENT') out.push({ file: name, type: '?', bytes: 0, mtimeMs: 0, title: '', description: `(读取失败: ${err?.message ?? err})` })
136
- }
137
- }
138
- out.sort((a, b) => b.mtimeMs - a.mtimeMs)
139
- return out
140
- }
141
-
142
- /**
143
- * 在 DSH 会话记录里做定向搜索。
144
- *
145
- * 会话正文是 zstd 压缩的(`session.v4.jsonl.zstd`),Node 24 的
146
- * `zlib.zstdDecompress` 能直接解 —— 不需要外部 zstd 可执行文件。
147
- * 只按 mtime 从新到旧扫,命中够数就停:这是「找昨天的报错原文」这类
148
- * 定向问题的用法,不是全量索引。
149
- *
150
- * @param {string} sessionsRoot — `$DSH_HOME/sessions`
151
- * @param {string} query
152
- * @param {number} limit
153
- * @param {number} sinceMs — 只看这个时间之后改过的会话
154
- * @returns {Promise<string>}
155
- */
156
- async function searchSessions(sessionsRoot, query, limit, sinceMs) {
157
- if (!sessionsRoot) return '(未配置 sessions 目录,无法搜索会话记录)'
158
- const needle = String(query || '').toLowerCase()
159
- if (needle.length < 2) return '(关键词太短,至少 2 个字符)'
160
-
161
- let zlib
162
- try {
163
- zlib = await import('node:zlib')
164
- } catch {
165
- return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
166
- }
167
- if (typeof zlib.zstdDecompress !== 'function') {
168
- return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
169
- }
170
-
171
- let dirs
172
- try {
173
- const projects = await readdir(sessionsRoot, { withFileTypes: true })
174
- dirs = []
175
- for (const p of projects) {
176
- if (!p.isDirectory()) continue
177
- const projPath = join(sessionsRoot, p.name)
178
- const sessions = await readdir(projPath, { withFileTypes: true }).catch(() => [])
179
- for (const s of sessions) {
180
- if (!s.isDirectory()) continue
181
- const file = join(projPath, s.name, 'session.v4.jsonl.zstd')
182
- const info = await stat(file).catch(() => null)
183
- if (info && info.mtimeMs >= sinceMs) dirs.push({ file, mtimeMs: info.mtimeMs })
184
- }
185
- }
186
- } catch (err) {
187
- return `(读取 sessions 目录失败:${err?.message ?? err})`
188
- }
189
-
190
- dirs.sort((a, b) => b.mtimeMs - a.mtimeMs)
191
- const hits = []
192
- let total = 0
193
- for (const d of dirs.slice(0, 40)) {
194
- try {
195
- const raw = await readFile(d.file)
196
- const text = zlib.zstdDecompress(raw).toString('utf8')
197
- const lines = text.split(/\r?\n/)
198
- for (const line of lines) {
199
- if (line.length < 8) continue
200
- if (!line.toLowerCase().includes(needle)) continue
201
- hits.push(`[${localStamp(d.mtimeMs)}] ${line.length > 500 ? line.slice(0, 500) + '…' : line}`)
202
- total += line.length
203
- if (total > SEARCH_LIMIT || hits.length > limit) break
204
- }
205
- } catch {
206
- // 单个会话读不动就跳过:autodream 不该因为一个坏文件整体失败。
207
- }
208
- if (total > SEARCH_LIMIT || hits.length > limit) break
209
- }
210
- if (!hits.length) return `(在 ${sinceMs ? '上次整理之后' : '全部'}的会话记录里没找到含「${query}」的内容)`
211
- return `命中 ${hits.length} 段(按 mtime 由新到旧):\n\n${hits.join('\n')}`
212
- }
213
-
214
- /** JSON Schema 片段:工具参数。 */
215
- const str = (description) => ({ type: 'string', description })
216
-
217
- /**
218
- * 组装这一趟 autodream 能用的工具定义。
219
- * @param {{apply: boolean, withSessions: boolean}} opts
220
- * @returns {Array<{name:string,description:string,parameters:object}>}
221
- */
222
- export function autodreamToolSchemas(opts) {
223
- const tools = [
224
- {
225
- name: 'list_memory',
226
- description:
227
- '列出记忆目录里的全部条目(文件名 / 类型 / 体积 / 距今多少天 / 描述)。这是第一步:先看清已有什么,再决定改哪一条,避免造出重复文件。',
228
- parameters: { type: 'object', properties: {}, additionalProperties: false },
229
- },
230
- {
231
- name: 'read_memory',
232
- description: '读一条记忆的全文(frontmatter + 正文)。文件名要和 list_memory 返回的完全一致。',
233
- parameters: {
234
- type: 'object',
235
- properties: { file: str('记忆文件名,例如 feedback_example.md') },
236
- required: ['file'],
237
- additionalProperties: false,
238
- },
239
- },
240
- {
241
- name: 'audit_memory',
242
- description:
243
- '对记忆目录跑一遍结构审计:索引悬空 / 漏索引 / type 与文件名前缀不一致 / CRLF / 双链断链 / frontmatter 里被 YAML 当注释吞掉的「空格+#」。只读,不修改任何文件。',
244
- parameters: { type: 'object', properties: {}, additionalProperties: false },
245
- },
246
- ]
247
- if (opts.withSessions) {
248
- tools.push({
249
- name: 'search_sessions',
250
- description:
251
- '在 DSH 会话记录(zstd 压缩的 jsonl)里做定向关键词搜索,由新到旧。只在确实需要某个具体细节时才用(例如某次报错原文),不要用它通读会话。',
252
- parameters: {
253
- type: 'object',
254
- properties: {
255
- query: str('要搜的关键词,至少 2 个字符'),
256
- limit: { type: 'number', description: '最多返回多少段,默认 20' },
257
- },
258
- required: ['query'],
259
- additionalProperties: false,
260
- },
261
- })
262
- }
263
- if (opts.apply) {
264
- tools.push(
265
- {
266
- name: 'write_memory',
267
- description:
268
- '写入或整体覆盖一条记忆文件(frontmatter + 正文)。只允许 memory 目录顶层的 .md。整合新信息时优先改写已有文件,不要新建近似重复的文件。**reason 请尽量填写**:这些改动会连同缘由一起写进整理声明,人要靠它判断这次自动整理干得对不对;缺了不会被拒绝,但声明里会把那条标成「未自述」。',
269
- parameters: {
270
- type: 'object',
271
- properties: {
272
- file: str('记忆文件名,例如 project_example.md'),
273
- content: str('完整文件内容,必须以 --- 开头的 YAML frontmatter 起始'),
274
- reason: str(
275
- '强烈建议填。一句话说清为什么改这条、改了什么(例如「合并了重复的部署记录,删掉被推翻的旧路径」)。' +
276
- '缺了**不会**被拒绝——改动照样生效——但整理声明会把这条标成「未自述」,用宿主能追溯到的信息' +
277
- '(第几轮 / 什么操作 / 字节变化)顶替。硬拒的代价是丢掉一次本来正确的修正,那比缺一句解释更糟。',
278
- ),
279
- },
280
- required: ['file', 'content'],
281
- additionalProperties: false,
282
- },
283
- },
284
- {
285
- name: 'archive_memory',
286
- description:
287
- '把一条记忆移进 archive/ 子目录(等于退役:不再参与检索与星图,但文件仍在、可人工捞回)。用于被推翻的、或被别的记忆取代的条目。没有删除功能——归档是这里能造成的最强后果。',
288
- parameters: {
289
- type: 'object',
290
- properties: {
291
- file: str('要归档的记忆文件名'),
292
- reason: str('一句话说明为什么归档'),
293
- },
294
- required: ['file', 'reason'],
295
- additionalProperties: false,
296
- },
297
- },
298
- )
299
- }
300
- return tools
301
- }
302
-
303
- /**
304
- * 一条记忆文件当前的指纹。不存在时返回 `exist:false`(这不算错——新建文件的第一步)。
305
- *
306
- * 为什么留 sha256 而不只留体积:回滚前的「这文件还是我改完那一版吗」需要能判等,
307
- * 体积相同内容不同的情况在 markdown 里太常见了。
308
- */
309
- async function fileFingerprint(full) {
310
- try {
311
- const buf = await readFile(full)
312
- return { exist: true, bytes: buf.length, sha256: createHash('sha256').update(buf).digest('hex') }
313
- } catch (err) {
314
- if (err?.code === 'ENOENT') return { exist: false, bytes: 0, sha256: null }
315
- throw err
316
- }
317
- }
318
-
319
- /** 缘由的最短长度:比这更短的基本都是「整理了一下」这种没有信息量的敷衍。 */
320
- const MIN_REASON_CHARS = 4
321
-
322
- /**
323
- * 造一个工具执行器。返回的 run() 永远解析成字符串(失败信息也回给模型),
324
- * 不抛异常 —— 模型拿到错误描述才能自己改,而不是让整趟 autodream 崩掉。
325
- *
326
- * @param {{memoryDir:string, sessionsRoot:string, apply:boolean, withSessions:boolean}} opts
327
- * @returns {{run:(name:string, args:object)=>Promise<string>, touchedFiles:()=>string[], changes:()=>object[], warnings:()=>string[]}}
328
- */
329
- export function createToolRunner(opts) {
330
- const { memoryDir, sessionsRoot, apply, withSessions } = opts
331
- let listCache = null
332
- let auditCache = null
333
- const touched = []
334
- /** 结构化变更清单——「整理声明」与「文件级回滚」共用这一份数据。 */
335
- const changes = []
336
- /** 被拒绝的写操作(归档没给 reason 之类):**改动没有发生**,声明里单列一节。 */
337
- const warnings = []
338
- /**
339
- * 提示(advisory):**改动发生了**,只是有话要说(例如模型没自述缘由)。
340
- *
341
- * 与 warnings 分开是必须的:两者在「到底改没改」上完全相反,混在一个数组里渲染出去,
342
- * 就会出现「标题写着『未能落地的改动』、正文却告诉你『改动已经生效』」这种自相矛盾
343
- */
344
- const notes = []
345
- let seq = 0
346
- /** 当前处在 agent 循环的第几轮——未自述缘由时用它追溯「哪一步干的」。 */
347
- let currentStep = 0
348
-
349
- /**
350
- * 记一条变更。seq 是本次运行内的序号,从 1 开始。
351
- * @param {'create'|'update'|'archive'} op
352
- * @param {'model'|'auto'} reasonSource — model = 模型自述;auto = 宿主追溯(模型没给)
353
- */
354
- function changeRecord(op, file, reason, before, after, reasonSource = 'model') {
355
- seq += 1
356
- const at = Date.now()
357
- changes.push({
358
- seq,
359
- at,
360
- atHuman: localStamp(at),
361
- step: currentStep || null,
362
- file,
363
- op,
364
- reason,
365
- reasonSource,
366
- before,
367
- after,
368
- deltaBytes: after.bytes - before.bytes,
369
- })
370
- }
371
-
372
- return {
373
- /** 报告要写「改了哪些文件」,由这里记录。 */
374
- touchedFiles() {
375
- return [...touched]
376
- },
377
- /** 结构化变更清单(含缘由与前后指纹)。 */
378
- changes() {
379
- return changes.map((c) => ({ ...c }))
380
- },
381
- /** 被拒绝的写操作说明(改动没有发生)。 */
382
- warnings() {
383
- return [...warnings]
384
- },
385
- /** 提示(改动发生了,但有话要说)。 */
386
- notes() {
387
- return [...notes]
388
- },
389
- /** agent 循环每进一轮调一次,供未自述缘由时追溯来源。 */
390
- setStep(n) {
391
- currentStep = Math.max(0, Math.round(Number(n) || 0))
392
- },
393
-
394
- async run(name, args) {
395
- const a = args && typeof args === 'object' ? args : {}
396
- try {
397
- switch (name) {
398
- case 'list_memory': {
399
- if (!listCache) listCache = await scanEntries(memoryDir)
400
- const lines = listCache.map(
401
- (e) =>
402
- `- ${e.file} · ${e.type} · ${Math.round(e.bytes / 1024)}KB · ${ageDays(e.mtimeMs)}天前 · ${localStamp(e.mtimeMs)}\n ${(e.title || '(无标题)').slice(0, 60)}${e.description ? ' — ' + e.description.slice(0, LIST_DESC_CHARS) : ''}`,
403
- )
404
- return `记忆目录:${memoryDir}\n共 ${listCache.length} 条(按修改时间由新到旧):\n\n${lines.join('\n')}`
405
- }
406
- case 'read_memory': {
407
- const validated = safeMemoryPath(memoryDir, a.file)
408
- if (!validated) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
409
- // 统一落到磁盘上的真实名字:NTFS 大小写不敏感,`A.md` 与 `a.md` 是同一个文件。
410
- const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
411
- const content = await readFile(full, 'utf8').catch((err) => {
412
- if (err?.code === 'ENOENT') return null
413
- throw err
414
- })
415
- if (content === null) return `错误:没有这个文件:${basename(full)}`
416
- if (content.length > READ_LIMIT) {
417
- return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符)`
418
- }
419
- return content
420
- }
421
- case 'audit_memory': {
422
- if (!auditCache) auditCache = await auditMemoryDir(memoryDir)
423
- return formatAudit(auditCache, 20)
424
- }
425
- case 'search_sessions': {
426
- if (!withSessions) return '错误:本次 autodream 的输入源不包含会话记录,search_sessions 不可用。'
427
- const lim = Number.isFinite(a.limit) ? Math.max(1, Math.min(60, Number(a.limit))) : 20
428
- return await searchSessions(sessionsRoot, a.query, lim, 0)
429
- }
430
- case 'write_memory': {
431
- if (!apply) return '错误:当前是「只出报告」模式,没有写权限。请把改动写进你的最终回复。'
432
- const validated = safeMemoryPath(memoryDir, a.file)
433
- if (!validated) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
434
- // 落盘前先解析成磁盘真实名字,否则变更记录与快照清单会各记一个名字,
435
- // 文件级回滚就找不到原版(RB-9)。
436
- const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
437
- if (typeof a.content !== 'string' || !a.content.trim()) return '错误:content 为空。'
438
- if (!/^---\r?\n/.test(a.content)) {
439
- return '错误:内容必须以 `---` 开头的 YAML frontmatter 起始。参考 list_memory 里已有文件的写法。'
440
- }
441
- if (a.content.length > 512 * 1024) return `错误:内容过大(${a.content.length} 字符)。`
442
- const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
443
- const selfReported = reason.length >= MIN_REASON_CHARS
444
- const before = await fileFingerprint(full)
445
- await writeFile(full, a.content, 'utf8')
446
- const after = await fileFingerprint(full)
447
- if (!selfReported) {
448
- // **不拒绝。** 一次改写缺了缘由,拒掉的代价是这条修正彻底丢失——而它可能
449
- // 正是这趟整理最该做的事。「照写 + 标未自述 + 用宿主能追溯到的信息顶替」
450
- // 既保住了改动,也让声明里一眼看得出哪些缘由是模型自己说的。
451
- notes.push(`${basename(full)} 的改动模型没有自述缘由,声明里已标「未自述」并附追溯信息`) }
452
- changeRecord(
453
- before.exist ? 'update' : 'create',
454
- basename(full),
455
- selfReported ? reason : '',
456
- before,
457
- after,
458
- selfReported ? 'model' : 'auto',
459
- )
460
- touched.push(basename(full))
461
- listCache = null
462
- auditCache = null
463
- return `已写入 ${basename(full)}(${a.content.length} 字符)${before.exist ? '' : '(新建)'}。`
464
- }
465
- case 'archive_memory': {
466
- if (!apply) return '错误:当前是「只出报告」模式,没有归档权限。请把建议写进你的最终回复。'
467
- const validated = safeMemoryPath(memoryDir, a.file)
468
- if (!validated) return `错误:文件名不合法:${String(a.file)}`
469
- const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
470
- const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
471
- if (reason.length < MIN_REASON_CHARS) {
472
- warnings.push(`拒绝对 ${basename(full)} 的归档:模型没给 reason(或太短)`)
473
- return '错误:archive_memory 必须给 reason —— 一句话说明为什么这条该退役。请带上 reason 重发。'
474
- }
475
- const exists = await stat(full).catch(() => null)
476
- if (!exists) return `错误:没有这个文件:${basename(full)}`
477
- const archiveDir = join(memoryDir, ARCHIVE_DIR)
478
- await mkdir(archiveDir, { recursive: true })
479
- const target = join(archiveDir, basename(full))
480
- const clash = await stat(target).catch(() => null)
481
- if (clash) return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
482
- const before = await fileFingerprint(full)
483
- await rename(full, target)
484
- changeRecord('archive', basename(full), reason, before, { exist: false, bytes: 0, sha256: null })
485
- touched.push(`${basename(full)} → archive/`)
486
- listCache = null
487
- auditCache = null
488
- return `已归档 ${basename(full)}(原因:${reason})。`
489
- }
490
- default:
491
- return `错误:没有这个工具:${name}`
492
- }
493
- } catch (err) {
494
- return `工具执行失败:${err?.message ?? String(err)}`
495
- }
496
- },
497
- }
498
- }
1
+ /**
2
+ * sage-mem autodream — 交给 autodream 模型的工具集,以及它们的 host 侧执行器。
3
+ *
4
+ * 这一层是**安全边界**,不是提示词。常见做法是「让模型随便请求、宿主逐个裁决」;
5
+ * 这里换了个更硬的写法 —— **模型只能看见这几个工具**,
6
+ * 而每个工具的宿主实现只认 memory 目录顶层的 `.md` 文件名。模型想越界
7
+ * (写别的目录、动子目录、写非 md、路径穿越)不是「被拒绝」,是**没有这个能力**。
8
+ *
9
+ * 还有两条刻意的不对称:
10
+ * - 「只出报告」模式下,写工具**根本不注册** —— 不是让模型别写,是它没有手。
11
+ * - 删除也不是删除:archive_memory 把文件移进 `archive/`,可人工捞回。
12
+ * memory 目录不在任何版本控制下、unlink 没有回收站,所以不留真删这条路径。
13
+ *
14
+ * **每一次写操作都必须留下「改了什么、为什么」**:
15
+ * - `write_memory` 的 `reason` **强烈建议**但不强制:缺了照样写入,声明里标成「未自述」
16
+ * 并用宿主能追溯到的信息(第几轮 / 操作 / 字节变化)顶替。**硬拒的代价是丢掉一次
17
+ * 本来正确的修正**,那比缺一句解释更糟(由参考设计调研推翻硬拒方案)。
18
+ * - `archive_memory` 的 `reason` 仍**必填**:归档是不可逆的退役动作,必须有人能说清为什么。
19
+ * - 每次成功写入/归档都产出一条结构化 `change`(前后指纹 + 缘由 + 缘由来源 + 字节差),
20
+ * 由 `changes()` 交给上层落成「整理声明」。**回滚也靠它**:只有知道动过哪几个文件、
21
+ * 动之前长什么样,才谈得上把文件级回滚做准。
22
+ *
23
+ * 模型与宿主对同一份数据的理解必须一致:`list_memory` 的字段顺序、
24
+ * `write_memory` 的 frontmatter 规范都沿用 sage-mem 的既有约定,不另造一套。
25
+ */
26
+
27
+ import { readFile, readdir, writeFile, stat } from 'node:fs/promises'
28
+ import { join, basename } from 'node:path'
29
+ import { createHash } from 'node:crypto'
30
+ import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
31
+ import { archiveMemory } from './memory/archive.js'
32
+
33
+ /** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
34
+ const READ_LIMIT = 20000
35
+ /** list_memory 里每条描述保留多少字。 */
36
+ const LIST_DESC_CHARS = 70
37
+ /** search_sessions 单次返回上限(字符)。 */
38
+ const SEARCH_LIMIT = 8000
39
+ /** 索引文件名:它在列表里要单独标注,免得模型当成一条普通记忆去改。 */
40
+ const INDEX_FILE = 'memory.md'
41
+ /**
42
+ * 非记忆条目:不进 list_memory。
43
+ *
44
+ * session-log.md 是追加型流水,不是一条记忆 —— 列出来既
45
+ * 占掉模型一大块注意力,又诱导它去「整理」一份流水账。冒烟测试
46
+ * 逮到的就是这条:它出现在了 list_memory 的输出里。
47
+ */
48
+ const NON_ENTRY_FILES = new Set(['session-log.md'])
49
+
50
+ /**
51
+ * 把一个模型给的文件名收敛成 memory 目录顶层的一个真实路径。
52
+ *
53
+ * 返回 null 表示「这个请求不合法」——调用方一律当成工具执行失败回给模型,
54
+ * 不抛异常打断整个 autodream(模型写错一次文件名不该毁掉整趟整理)。
55
+ *
56
+ * @param {string} memoryDir — 记忆根目录
57
+ * @param {unknown} raw — 模型给的 file 参数
58
+ * @returns {string|null} 绝对路径,或 null
59
+ */
60
+ export function safeMemoryPath(memoryDir, raw) {
61
+ if (typeof raw !== 'string') return null
62
+ const trimmed = raw.trim()
63
+ if (!trimmed) return null
64
+ // 显式拒绝任何路径分隔与上跳:basename 只做兜底,先拒绝更清楚。
65
+ if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) return null
66
+ if (!/\.md$/i.test(trimmed)) return null
67
+ const base = basename(trimmed)
68
+ if (base.startsWith('.')) return null
69
+ return join(memoryDir, base)
70
+ }
71
+
72
+ /**
73
+ * 把模型给的文件名解析成**磁盘上的真实名字**。
74
+ *
75
+ * 为什么必须有这一步:NTFS 不区分大小写 —— 模型写 `A.md` 而盘上是 `a.md` 时,
76
+ * 读写命中的是**同一个文件**。但变更记录、快照清单、回滚时用的都是「模型给的那个名字」,
77
+ * 于是回滚会去快照里找一个不存在的 `A.md`,**明明有原版却退不回去**,
78
+ * 而且留痕还写着「回滚点里没有这一版,可能已被别的整理改过」——理由是错的。
79
+ *
80
+ * 文件不存在时按原样返回(新建场景)。
81
+ */
82
+ async function resolveRealName(memoryDir, base) {
83
+ const lower = base.toLowerCase()
84
+ const entries = await readdir(memoryDir, { withFileTypes: true }).catch(() => [])
85
+ const hit = entries.find((e) => e.isFile() && e.name.toLowerCase() === lower)
86
+ return hit ? hit.name : base
87
+ }
88
+
89
+ /** ISO 时间 → 本地 `YYYY-MM-DD HH:mm`(报告与列表里给人看)。 */function localStamp(ms) {
90
+ const d = new Date(ms)
91
+ const p = (n) => String(n).padStart(2, '0')
92
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
93
+ }
94
+
95
+ /** 距今多少天,给模型一个人话尺度。 */
96
+ function ageDays(ms) {
97
+ return Math.round(((Date.now() - ms) / 86400000) * 10) / 10
98
+ }
99
+
100
+ /**
101
+ * 扫记忆目录,产出列表用的轻量元数据(不读全文)。
102
+ * @param {string} memoryDir
103
+ * @returns {Promise<Array<object>>}
104
+ */
105
+ async function scanEntries(memoryDir) {
106
+ let entries
107
+ try {
108
+ entries = await readdir(memoryDir, { withFileTypes: true })
109
+ } catch (err) {
110
+ if (err?.code === 'ENOENT') return []
111
+ throw err
112
+ }
113
+ const names = entries
114
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !NON_ENTRY_FILES.has(e.name.toLowerCase()))
115
+ .map((e) => e.name)
116
+ const out = []
117
+ for (const name of names) {
118
+ const full = join(memoryDir, name)
119
+ try {
120
+ const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
121
+ const meta = parseFrontmatterLite(content)
122
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '')
123
+ const h1 = body.match(/^#\s+(.+)$/m)
124
+ out.push({
125
+ file: name,
126
+ // 索引不是一条记忆:标出来,否则模型会看到「MEMORY.md · reference」而困惑。
127
+ type: name.toLowerCase() === INDEX_FILE ? '索引' : meta.type || 'reference',
128
+ bytes: info.size,
129
+ mtimeMs: info.mtimeMs,
130
+ title: (h1 ? h1[1] : meta.name || '').trim(),
131
+ description: meta.description || '',
132
+ })
133
+ } catch (err) {
134
+ if (err?.code !== 'ENOENT') out.push({ file: name, type: '?', bytes: 0, mtimeMs: 0, title: '', description: `(读取失败: ${err?.message ?? err})` })
135
+ }
136
+ }
137
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs)
138
+ return out
139
+ }
140
+
141
+ /**
142
+ * 在 DSH 会话记录里做定向搜索。
143
+ *
144
+ * 会话正文是 zstd 压缩的(`session.v4.jsonl.zstd`),Node 24 的
145
+ * `zlib.zstdDecompress` 能直接解 —— 不需要外部 zstd 可执行文件。
146
+ * 只按 mtime 从新到旧扫,命中够数就停:这是「找昨天的报错原文」这类
147
+ * 定向问题的用法,不是全量索引。
148
+ *
149
+ * @param {string} sessionsRoot — `$DSH_HOME/sessions`
150
+ * @param {string} query
151
+ * @param {number} limit
152
+ * @param {number} sinceMs — 只看这个时间之后改过的会话
153
+ * @returns {Promise<string>}
154
+ */
155
+ async function searchSessions(sessionsRoot, query, limit, sinceMs) {
156
+ if (!sessionsRoot) return '(未配置 sessions 目录,无法搜索会话记录)'
157
+ const needle = String(query || '').toLowerCase()
158
+ if (needle.length < 2) return '(关键词太短,至少 2 个字符)'
159
+
160
+ let zlib
161
+ try {
162
+ zlib = await import('node:zlib')
163
+ } catch {
164
+ return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
165
+ }
166
+ if (typeof zlib.zstdDecompress !== 'function') {
167
+ return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
168
+ }
169
+
170
+ let dirs
171
+ try {
172
+ const projects = await readdir(sessionsRoot, { withFileTypes: true })
173
+ dirs = []
174
+ for (const p of projects) {
175
+ if (!p.isDirectory()) continue
176
+ const projPath = join(sessionsRoot, p.name)
177
+ const sessions = await readdir(projPath, { withFileTypes: true }).catch(() => [])
178
+ for (const s of sessions) {
179
+ if (!s.isDirectory()) continue
180
+ const file = join(projPath, s.name, 'session.v4.jsonl.zstd')
181
+ const info = await stat(file).catch(() => null)
182
+ if (info && info.mtimeMs >= sinceMs) dirs.push({ file, mtimeMs: info.mtimeMs })
183
+ }
184
+ }
185
+ } catch (err) {
186
+ return `(读取 sessions 目录失败:${err?.message ?? err})`
187
+ }
188
+
189
+ dirs.sort((a, b) => b.mtimeMs - a.mtimeMs)
190
+ const hits = []
191
+ let total = 0
192
+ for (const d of dirs.slice(0, 40)) {
193
+ try {
194
+ const raw = await readFile(d.file)
195
+ const text = zlib.zstdDecompress(raw).toString('utf8')
196
+ const lines = text.split(/\r?\n/)
197
+ for (const line of lines) {
198
+ if (line.length < 8) continue
199
+ if (!line.toLowerCase().includes(needle)) continue
200
+ hits.push(`[${localStamp(d.mtimeMs)}] ${line.length > 500 ? line.slice(0, 500) + '…' : line}`)
201
+ total += line.length
202
+ if (total > SEARCH_LIMIT || hits.length > limit) break
203
+ }
204
+ } catch {
205
+ // 单个会话读不动就跳过:autodream 不该因为一个坏文件整体失败。
206
+ }
207
+ if (total > SEARCH_LIMIT || hits.length > limit) break
208
+ }
209
+ if (!hits.length) return `(在 ${sinceMs ? '上次整理之后' : '全部'}的会话记录里没找到含「${query}」的内容)`
210
+ return `命中 ${hits.length} 段(按 mtime 由新到旧):\n\n${hits.join('\n')}`
211
+ }
212
+
213
+ /** JSON Schema 片段:工具参数。 */
214
+ const str = (description) => ({ type: 'string', description })
215
+
216
+ /**
217
+ * 组装这一趟 autodream 能用的工具定义。
218
+ * @param {{apply: boolean, withSessions: boolean}} opts
219
+ * @returns {Array<{name:string,description:string,parameters:object}>}
220
+ */
221
+ export function autodreamToolSchemas(opts) {
222
+ const tools = [
223
+ {
224
+ name: 'list_memory',
225
+ description:
226
+ '列出记忆目录里的全部条目(文件名 / 类型 / 体积 / 距今多少天 / 描述)。这是第一步:先看清已有什么,再决定改哪一条,避免造出重复文件。',
227
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
228
+ },
229
+ {
230
+ name: 'read_memory',
231
+ description: '读一条记忆的全文(frontmatter + 正文)。文件名要和 list_memory 返回的完全一致。',
232
+ parameters: {
233
+ type: 'object',
234
+ properties: { file: str('记忆文件名,例如 feedback_example.md') },
235
+ required: ['file'],
236
+ additionalProperties: false,
237
+ },
238
+ },
239
+ {
240
+ name: 'audit_memory',
241
+ description:
242
+ '对记忆目录跑一遍结构审计:索引悬空 / 漏索引 / type 与文件名前缀不一致 / CRLF / 双链断链 / frontmatter 里被 YAML 当注释吞掉的「空格+#」。只读,不修改任何文件。',
243
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
244
+ },
245
+ ]
246
+ if (opts.withSessions) {
247
+ tools.push({
248
+ name: 'search_sessions',
249
+ description:
250
+ '在 DSH 会话记录(zstd 压缩的 jsonl)里做定向关键词搜索,由新到旧。只在确实需要某个具体细节时才用(例如某次报错原文),不要用它通读会话。',
251
+ parameters: {
252
+ type: 'object',
253
+ properties: {
254
+ query: str('要搜的关键词,至少 2 个字符'),
255
+ limit: { type: 'number', description: '最多返回多少段,默认 20' },
256
+ },
257
+ required: ['query'],
258
+ additionalProperties: false,
259
+ },
260
+ })
261
+ }
262
+ if (opts.apply) {
263
+ tools.push(
264
+ {
265
+ name: 'write_memory',
266
+ description:
267
+ '写入或整体覆盖一条记忆文件(frontmatter + 正文)。只允许 memory 目录顶层的 .md。整合新信息时优先改写已有文件,不要新建近似重复的文件。**reason 请尽量填写**:这些改动会连同缘由一起写进整理声明,人要靠它判断这次自动整理干得对不对;缺了不会被拒绝,但声明里会把那条标成「未自述」。',
268
+ parameters: {
269
+ type: 'object',
270
+ properties: {
271
+ file: str('记忆文件名,例如 project_example.md'),
272
+ content: str('完整文件内容,必须以 --- 开头的 YAML frontmatter 起始'),
273
+ reason: str(
274
+ '强烈建议填。一句话说清为什么改这条、改了什么(例如「合并了重复的部署记录,删掉被推翻的旧路径」)。' +
275
+ '缺了**不会**被拒绝——改动照样生效——但整理声明会把这条标成「未自述」,用宿主能追溯到的信息' +
276
+ '(第几轮 / 什么操作 / 字节变化)顶替。硬拒的代价是丢掉一次本来正确的修正,那比缺一句解释更糟。',
277
+ ),
278
+ },
279
+ required: ['file', 'content'],
280
+ additionalProperties: false,
281
+ },
282
+ },
283
+ {
284
+ name: 'archive_memory',
285
+ description:
286
+ '把一条记忆移进 archive/ 子目录(等于退役:不再参与检索与星图,但文件仍在、可人工捞回)。用于被推翻的、或被别的记忆取代的条目。没有删除功能——归档是这里能造成的最强后果。',
287
+ parameters: {
288
+ type: 'object',
289
+ properties: {
290
+ file: str('要归档的记忆文件名'),
291
+ reason: str('一句话说明为什么归档'),
292
+ },
293
+ required: ['file', 'reason'],
294
+ additionalProperties: false,
295
+ },
296
+ },
297
+ )
298
+ }
299
+ return tools
300
+ }
301
+
302
+ /**
303
+ * 一条记忆文件当前的指纹。不存在时返回 `exist:false`(这不算错——新建文件的第一步)。
304
+ *
305
+ * 为什么留 sha256 而不只留体积:回滚前的「这文件还是我改完那一版吗」需要能判等,
306
+ * 体积相同内容不同的情况在 markdown 里太常见了。
307
+ */
308
+ async function fileFingerprint(full) {
309
+ try {
310
+ const buf = await readFile(full)
311
+ return { exist: true, bytes: buf.length, sha256: createHash('sha256').update(buf).digest('hex') }
312
+ } catch (err) {
313
+ if (err?.code === 'ENOENT') return { exist: false, bytes: 0, sha256: null }
314
+ throw err
315
+ }
316
+ }
317
+
318
+ /** 缘由的最短长度:比这更短的基本都是「整理了一下」这种没有信息量的敷衍。 */
319
+ const MIN_REASON_CHARS = 4
320
+
321
+ /**
322
+ * 造一个工具执行器。返回的 run() 永远解析成字符串(失败信息也回给模型),
323
+ * 不抛异常 —— 模型拿到错误描述才能自己改,而不是让整趟 autodream 崩掉。
324
+ *
325
+ * @param {{memoryDir:string, sessionsRoot:string, apply:boolean, withSessions:boolean}} opts
326
+ * @returns {{run:(name:string, args:object)=>Promise<string>, touchedFiles:()=>string[], changes:()=>object[], warnings:()=>string[]}}
327
+ */
328
+ export function createToolRunner(opts) {
329
+ const { memoryDir, sessionsRoot, apply, withSessions } = opts
330
+ let listCache = null
331
+ let auditCache = null
332
+ const touched = []
333
+ /** 结构化变更清单——「整理声明」与「文件级回滚」共用这一份数据。 */
334
+ const changes = []
335
+ /** 被拒绝的写操作(归档没给 reason 之类):**改动没有发生**,声明里单列一节。 */
336
+ const warnings = []
337
+ /**
338
+ * 提示(advisory):**改动发生了**,只是有话要说(例如模型没自述缘由)。
339
+ *
340
+ * 与 warnings 分开是必须的:两者在「到底改没改」上完全相反,混在一个数组里渲染出去,
341
+ * 就会出现「标题写着『未能落地的改动』、正文却告诉你『改动已经生效』」这种自相矛盾
342
+ */
343
+ const notes = []
344
+ let seq = 0
345
+ /** 当前处在 agent 循环的第几轮——未自述缘由时用它追溯「哪一步干的」。 */
346
+ let currentStep = 0
347
+
348
+ /**
349
+ * 记一条变更。seq 是本次运行内的序号,从 1 开始。
350
+ * @param {'create'|'update'|'archive'} op
351
+ * @param {'model'|'auto'} reasonSource — model = 模型自述;auto = 宿主追溯(模型没给)
352
+ */
353
+ function changeRecord(op, file, reason, before, after, reasonSource = 'model') {
354
+ seq += 1
355
+ const at = Date.now()
356
+ changes.push({
357
+ seq,
358
+ at,
359
+ atHuman: localStamp(at),
360
+ step: currentStep || null,
361
+ file,
362
+ op,
363
+ reason,
364
+ reasonSource,
365
+ before,
366
+ after,
367
+ deltaBytes: after.bytes - before.bytes,
368
+ })
369
+ }
370
+
371
+ return {
372
+ /** 报告要写「改了哪些文件」,由这里记录。 */
373
+ touchedFiles() {
374
+ return [...touched]
375
+ },
376
+ /** 结构化变更清单(含缘由与前后指纹)。 */
377
+ changes() {
378
+ return changes.map((c) => ({ ...c }))
379
+ },
380
+ /** 被拒绝的写操作说明(改动没有发生)。 */
381
+ warnings() {
382
+ return [...warnings]
383
+ },
384
+ /** 提示(改动发生了,但有话要说)。 */
385
+ notes() {
386
+ return [...notes]
387
+ },
388
+ /** agent 循环每进一轮调一次,供未自述缘由时追溯来源。 */
389
+ setStep(n) {
390
+ currentStep = Math.max(0, Math.round(Number(n) || 0))
391
+ },
392
+
393
+ async run(name, args) {
394
+ const a = args && typeof args === 'object' ? args : {}
395
+ try {
396
+ switch (name) {
397
+ case 'list_memory': {
398
+ if (!listCache) listCache = await scanEntries(memoryDir)
399
+ const lines = listCache.map(
400
+ (e) =>
401
+ `- ${e.file} · ${e.type} · ${Math.round(e.bytes / 1024)}KB · ${ageDays(e.mtimeMs)}天前 · ${localStamp(e.mtimeMs)}\n ${(e.title || '(无标题)').slice(0, 60)}${e.description ? ' — ' + e.description.slice(0, LIST_DESC_CHARS) : ''}`,
402
+ )
403
+ return `记忆目录:${memoryDir}\n共 ${listCache.length} 条(按修改时间由新到旧):\n\n${lines.join('\n')}`
404
+ }
405
+ case 'read_memory': {
406
+ const validated = safeMemoryPath(memoryDir, a.file)
407
+ if (!validated) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
408
+ // 统一落到磁盘上的真实名字:NTFS 大小写不敏感,`A.md` 与 `a.md` 是同一个文件。
409
+ const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
410
+ const content = await readFile(full, 'utf8').catch((err) => {
411
+ if (err?.code === 'ENOENT') return null
412
+ throw err
413
+ })
414
+ if (content === null) return `错误:没有这个文件:${basename(full)}`
415
+ if (content.length > READ_LIMIT) {
416
+ return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符)`
417
+ }
418
+ return content
419
+ }
420
+ case 'audit_memory': {
421
+ if (!auditCache) auditCache = await auditMemoryDir(memoryDir)
422
+ return formatAudit(auditCache, 20)
423
+ }
424
+ case 'search_sessions': {
425
+ if (!withSessions) return '错误:本次 autodream 的输入源不包含会话记录,search_sessions 不可用。'
426
+ const lim = Number.isFinite(a.limit) ? Math.max(1, Math.min(60, Number(a.limit))) : 20
427
+ return await searchSessions(sessionsRoot, a.query, lim, 0)
428
+ }
429
+ case 'write_memory': {
430
+ if (!apply) return '错误:当前是「只出报告」模式,没有写权限。请把改动写进你的最终回复。'
431
+ const validated = safeMemoryPath(memoryDir, a.file)
432
+ if (!validated) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
433
+ // 落盘前先解析成磁盘真实名字,否则变更记录与快照清单会各记一个名字,
434
+ // 文件级回滚就找不到原版(RB-9)。
435
+ const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
436
+ if (typeof a.content !== 'string' || !a.content.trim()) return '错误:content 为空。'
437
+ if (!/^---\r?\n/.test(a.content)) {
438
+ return '错误:内容必须以 `---` 开头的 YAML frontmatter 起始。参考 list_memory 里已有文件的写法。'
439
+ }
440
+ if (a.content.length > 512 * 1024) return `错误:内容过大(${a.content.length} 字符)。`
441
+ const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
442
+ const selfReported = reason.length >= MIN_REASON_CHARS
443
+ const before = await fileFingerprint(full)
444
+ await writeFile(full, a.content, 'utf8')
445
+ const after = await fileFingerprint(full)
446
+ if (!selfReported) {
447
+ // **不拒绝。** 一次改写缺了缘由,拒掉的代价是这条修正彻底丢失——而它可能
448
+ // 正是这趟整理最该做的事。「照写 + 标未自述 + 用宿主能追溯到的信息顶替」
449
+ // 既保住了改动,也让声明里一眼看得出哪些缘由是模型自己说的。
450
+ notes.push(`${basename(full)} 的改动模型没有自述缘由,声明里已标「未自述」并附追溯信息`) }
451
+ changeRecord(
452
+ before.exist ? 'update' : 'create',
453
+ basename(full),
454
+ selfReported ? reason : '',
455
+ before,
456
+ after,
457
+ selfReported ? 'model' : 'auto',
458
+ )
459
+ touched.push(basename(full))
460
+ listCache = null
461
+ auditCache = null
462
+ return `已写入 ${basename(full)}(${a.content.length} 字符)${before.exist ? '' : '(新建)'}。`
463
+ }
464
+ case 'archive_memory': {
465
+ if (!apply) return '错误:当前是「只出报告」模式,没有归档权限。请把建议写进你的最终回复。'
466
+ const validated = safeMemoryPath(memoryDir, a.file)
467
+ if (!validated) return `错误:文件名不合法:${String(a.file)}`
468
+ const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
469
+ // reason 必填这条语义由**工具自己**守(模型必须为不可逆动作说清为什么);
470
+ // 落盘动作则交给 memory/archive.js —— 与手动 remote、自动策略共用同一份。
471
+ const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
472
+ if (reason.length < MIN_REASON_CHARS) {
473
+ warnings.push(`拒绝对 ${basename(full)} 的归档:模型没给 reason(或太短)`)
474
+ return '错误:archive_memory 必须给 reason —— 一句话说明为什么这条该退役。请带上 reason 重发。'
475
+ }
476
+ const before = await fileFingerprint(full)
477
+ const res = await archiveMemory(memoryDir, basename(full), reason)
478
+ if (!res.ok) {
479
+ if (res.code === 'not-found') return `错误:没有这个文件:${basename(full)}`
480
+ if (res.code === 'exists') return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
481
+ return `错误:${String(res.error).replace(/^sage-mem: /, '')}`
482
+ }
483
+ changeRecord('archive', basename(full), reason, before, { exist: false, bytes: 0, sha256: null })
484
+ touched.push(`${basename(full)} → archive/`)
485
+ listCache = null
486
+ auditCache = null
487
+ return `已归档 ${basename(full)}(原因:${reason})。`
488
+ }
489
+ default:
490
+ return `错误:没有这个工具:${name}`
491
+ }
492
+ } catch (err) {
493
+ return `工具执行失败:${err?.message ?? String(err)}`
494
+ }
495
+ },
496
+ }
497
+ }