sage-mem 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * sage-mem dream — 记忆目录审计(六查)。
2
+ * sage-mem autodream — 记忆目录审计(七查)。
3
3
  *
4
- * 把 `sage-memory` 技能里的审计清单搬到 host 侧,让 dream 每次跑完都能自证
5
- * 没把记忆改坏:
4
+ * 审计清单内置在 host 侧,让 autodream 每次跑完都能自证
5
+ * 没把记忆改坏(七项,外加「读不动」单列一类):
6
6
  * 1. 索引悬空 —— MEMORY.md 指向不存在的文件
7
7
  * 2. 漏索引 —— 存在但没进索引的记忆文件
8
8
  * 3. type 与前缀不一致(分类看 type,前缀骗不了它)
@@ -10,7 +10,7 @@
10
10
  * 5. 双链断链 —— [[目标]] 找不到对应文件或 frontmatter name
11
11
  * 6. 未加引号值里的「空格+#」—— 会被 YAML 当注释吞掉,解析不报错但描述残缺
12
12
  *
13
- * **纯只读**:不修任何东西。修哪条、怎么修,由 dream agent 或人决定。
13
+ * **纯只读**:不修任何东西。修哪条、怎么修,由 autodream agent 或人决定。
14
14
  *
15
15
  * 这一份解析是 sage-mem host 半 parseFrontmatter 的同源精简版,故意不共用:
16
16
  * 那边带着一长串历史坑的注释与行为(baseline、type 回落),审计只读不写,
@@ -26,7 +26,7 @@ const INDEX_FILE = 'MEMORY.md'
26
26
  /**
27
27
  * 非记忆条目:不是四类记忆、也不该套「type 与前缀一致」这条规则。
28
28
  *
29
- * session-log.md 是追加型流水(本机已 390+ KB),逐会话的账本而不是一条记忆;
29
+ * session-log.md 是追加型流水,逐会话的账本而不是一条记忆;
30
30
  * 它没有 `user_/feedback_/project_/reference_` 前缀,扫进来只会白白读一大坨
31
31
  * 再报一条假问题(2026-09-27 实测:报出 `session-log.md type=reference`)。
32
32
  * 同理 MEMORY.md 是索引本身,只作为链接来源读,不作为被审对象。
@@ -98,7 +98,7 @@ async function readDirSnapshot(dir) {
98
98
  }
99
99
 
100
100
  /**
101
- * 六查主入口。
101
+ * 七查主入口。
102
102
  * @param {string} dir — 记忆根目录
103
103
  * @returns {Promise<object>} 结构化审计结果(problems 是六项之和)
104
104
  */
@@ -123,17 +123,30 @@ export async function auditMemoryDir(dir) {
123
123
  const linkKeys = new Set()
124
124
 
125
125
  const contents = new Map()
126
+ /**
127
+ * 读不动的文件(权限、被占用、编码坏)。
128
+ *
129
+ * **绝不抛**:`run()` 把「运行前审计」当作开工前置,裸抛一个 EPERM 会让
130
+ * 「有个文件读不了」升级成「自动整理整个停摆」,而且报出来的是宿主内部的原文。
131
+ * 它们单列一类,**不参与**后面每一项检查 —— 免得被戴上「没 frontmatter」「CRLF」的帽子
132
+ */
133
+ const unreadable = []
126
134
  for (const f of files) {
127
- contents.set(f, await readFile(join(dir, f), 'utf8'))
135
+ try {
136
+ contents.set(f, await readFile(join(dir, f), 'utf8'))
137
+ } catch {
138
+ unreadable.push(f)
139
+ }
128
140
  }
129
- for (const f of files) {
141
+ const readable = files.filter((f) => contents.has(f))
142
+ for (const f of readable) {
130
143
  const meta = parseFrontmatterLite(contents.get(f))
131
144
  if (meta.name) linkKeys.add(meta.name.trim())
132
145
  }
133
- for (const f of files) linkKeys.add(f)
134
- for (const f of files) linkKeys.add(f.replace(/\.md$/, ''))
146
+ for (const f of readable) linkKeys.add(f)
147
+ for (const f of readable) linkKeys.add(f.replace(/\.md$/, ''))
135
148
 
136
- for (const f of entries) {
149
+ for (const f of readable) {
137
150
  const raw = contents.get(f)
138
151
  if (raw.includes('\r')) crlf.push(f)
139
152
  const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
@@ -166,7 +179,8 @@ export async function auditMemoryDir(dir) {
166
179
  crlf.length +
167
180
  brokenLinks.length +
168
181
  hashHazards.length +
169
- noFrontmatter.length
182
+ noFrontmatter.length +
183
+ unreadable.length
170
184
 
171
185
  return {
172
186
  dir,
@@ -180,12 +194,13 @@ export async function auditMemoryDir(dir) {
180
194
  brokenLinks,
181
195
  hashHazards,
182
196
  noFrontmatter: noFrontmatter.map((file) => ({ file })),
197
+ unreadable: unreadable.map((file) => ({ file })),
183
198
  problems,
184
199
  }
185
200
  }
186
201
 
187
202
  /**
188
- * 把审计结果压成给模型看的短文本(dream 工具返回值 / 报告附录都用它)。
203
+ * 把审计结果压成给模型看的短文本(autodream 工具返回值 / 报告附录都用它)。
189
204
  * @param {object} report — auditMemoryDir 的返回值
190
205
  * @param {number} [limit] — 每个分项最多列几条
191
206
  * @returns {string}
@@ -207,5 +222,8 @@ export function formatAudit(report, limit = 12) {
207
222
  section('双链断链', report.brokenLinks, (r) => `${r.file} -> [[${r.target}]]`)
208
223
  section('未加引号的「空格+#」', report.hashHazards, (r) => `${r.file} :: ${r.line}`)
209
224
  section('无 frontmatter', report.noFrontmatter, (r) => r.file)
225
+ // 读不动的单列,且排最后:它不是「这条记忆写错了」,是「我没读到它」——
226
+ // 别让模型以为去改改 frontmatter 就能好。
227
+ section('读不动(权限/占用/编码,不参与上面各项检查)', report.unreadable ?? [], (r) => r.file)
210
228
  return lines.join('\n')
211
229
  }
@@ -1,14 +1,16 @@
1
1
  /**
2
- * sage-mem dream — 提示词。
2
+ * sage-mem autodream — 提示词。
3
3
  *
4
4
  * 四阶段结构:定位 → 收集 → 整合 → 修剪与索引。两个非显然的取舍:
5
5
  *
6
- * 1. **写记忆的规范必须写进 prompt 里。** sage-mem 的规范本来在 `sage-memory`
7
- * 技能里,但 dream 是一次独立调用、不一定带着那份技能。所以四类前缀、
8
- * `type` 一致性、YAML 两个静默陷阱、保留名这些**必须**出现在 prompt 里,
9
- * 否则模型会照着「看起来像」的方式写坏。
6
+ * 1. **写记忆的规范必须随 prompt 自带。** autodream 是一次独立调用,带不上外部的
7
+ * 技能文档。所以四类前缀、`type` 一致性、YAML 两个静默陷阱、受保护文件名这些
8
+ * **必须**出现在 prompt 里,否则模型会照着「看起来像」的方式写坏。
10
9
  * 2. **按模式分叉。** 「只出报告」不是靠一句「请不要写」约束的——那一趟根本
11
- * 没有写工具(见 dream-tools.js)。所以 prompt 只说它有什么、该产出什么形状。
10
+ * 没有写工具(见 autodream-tools.js)。所以 prompt 只说它有什么、该产出什么形状。
11
+ *
12
+ * ⚠️ 这里**不许出现任何具体的记忆文件名**(除了使用者通过 `reserved.json` 自己声明的)。
13
+ * 那段文字会发给每一个使用者的模型;把作者的记忆主题编进去就是泄漏
12
14
  */
13
15
 
14
16
  /** 让 prompt 里的日期对人可读:模型算相对日期时不会写错。 */
@@ -20,13 +22,19 @@ function today(ms) {
20
22
 
21
23
  /**
22
24
  * 系统提示:身份 + 记忆系统的约定 + 这一趟的权限边界。
23
- * @param {{apply:boolean, withSessions:boolean}} opts
25
+ * @param {{apply:boolean, withSessions:boolean, reservedFiles?:string[]}} opts
26
+ * `reservedFiles` 是**使用者自己声明**的受保护文件名(来自 `<状态根>/reserved.json`)。
27
+ * 由运行时注入而不是写死在这里——见文件头那条警告。
24
28
  * @returns {string}
25
29
  */
26
- export function buildDreamSystemPrompt(opts) {
30
+ export function buildAutodreamSystemPrompt(opts) {
27
31
  const { apply, withSessions } = opts
32
+ const reserved = Array.isArray(opts.reservedFiles) ? opts.reservedFiles.filter(Boolean) : []
33
+ const reservedNote = reserved.length
34
+ ? `- **不许改动的文件**(使用者指定的保护名单):${reserved.map((f) => `\`${f}\``).join('、')} —— 这些多半是手写单副本、人工维护的重要记忆,一个字都不要动`
35
+ : '- 有些记忆是使用者手写单副本、人工维护的;本次没有额外指定保护名单,遇到明显「成篇手写、结构不像流水」的记忆请保守处理'
28
36
  const modeLine = apply
29
- ? '本次是**改写模式**:你有写工具,可以直接改记忆文件。运行前已经把整个 memory 目录快照了一份,改坏能整体回退——但这只保证「可恢复」,不保证「改得对」。宁可少改,不要乱动。'
37
+ ? '本次是**改写模式**:你有写工具,可以直接改记忆文件。运行前已经把整个 memory 目录快照了一份(这就是回滚点),改坏能整体退回去——但这只保证「可恢复」,不保证「改得对」。宁可少改,不要乱动。\n\n**每一次写入都请写清 `reason`。** 这一趟是无人值守跑的,人只能靠这份缘由判断你改得对不对。写清「为什么改这条、改成了什么」,不要写「整理优化」这种没有信息量的话。缺了**不会被拒绝**——改动照样生效——但整理声明里会把那条标成「未自述」,用宿主能追溯到的信息顶替。'
30
38
  : '本次是**只读模式**:你没有任何写工具。把所有发现写成建议清单,写进你最后的回复里——要具体到「哪个文件、改成什么」,不要泛泛而谈。'
31
39
 
32
40
  return `你是 sage-mem(一个文件式跨会话记忆系统)的整理进程。你的记忆不是数据库,是一个装着 markdown 文件的目录,靠 frontmatter 分类、靠双字组匹配检索。你要做的是一次反思性通读,把最近的经历沉淀成耐久、有条理的记忆。
@@ -57,7 +65,7 @@ ${modeLine}
57
65
  **几个特殊文件:**
58
66
  - \`MEMORY.md\` —— 索引。它是一份索引用,每条一行 \`- [标题](文件.md) — 一句话\`,**不要把记忆内容写进去**
59
67
  - \`session-log.md\` —— 追加型流水账,**不是一条记忆**,不进检索、不进列表、不要当记忆改
60
- - \`project_heartscape.md\` / \`project_self-cognition.md\` —— 手写单副本、人工维护,改动它们要格外小心
68
+ ${reservedNote}
61
69
  - \`archive/\` —— 归档区,不在检索范围内。归档 = 把文件移进去,**可人工捞回**;这里没有真删除
62
70
 
63
71
  **硬标准:靠读代码 / git / 系统提示能推出来的东西不要存。** 存的是推不出来的上下文。写之前先问一句「下一个我会真的用得上这条吗」。${withSessions ? '' : '\n\n**本次输入源不含会话记录**,所以你没有 search_sessions 工具。手上的材料就是记忆目录本身。'}`
@@ -72,7 +80,7 @@ ${modeLine}
72
80
  * @param {{memoryDir:string, apply:boolean, hoursSince:number, sessionCount:number, now:number}} opts
73
81
  * @returns {string}
74
82
  */
75
- export function buildDreamUserPrompt(opts) {
83
+ export function buildAutodreamUserPrompt(opts) {
76
84
  const { memoryDir, apply, hoursSince, sessionCount, now } = opts
77
85
  const stage4 = apply
78
86
  ? `- 更新 \`MEMORY.md\` 索引,让它保持是索引而不是内容堆:
@@ -80,9 +88,9 @@ export function buildDreamUserPrompt(opts) {
80
88
  - 移除指向已归档 / 已不存在文件的指针
81
89
  - 补上新写的重要记忆
82
90
  - 两条记忆互相矛盾时,把错的那条改对`
83
- : `- 给出 \`MEMORY.md\` 该增删哪些行的**具体清单**(照抄要改成的那行文本)`
91
+ : `- 给出 \`MEMORY.md\` 该增删哪些行的**具体清单**(逐字给出要改成的那行文本)`
84
92
 
85
- return `# Dream:一次记忆整理
93
+ return `# Autodream:一次记忆整理
86
94
 
87
95
  今天:${today(now)}
88
96
  记忆目录:\`${memoryDir}\`
@@ -109,7 +117,7 @@ export function buildDreamUserPrompt(opts) {
109
117
 
110
118
  对每件值得记住的事,改写或新建一条记忆。**合并新信息进已有文件**,不要为一点新东西新开一个文件。被推翻的事实改在源头,而不是在后面追加一句「其实不是这样」。
111
119
 
112
- ${apply ? '你有写工具,直接落盘。' : '你没有写工具,把每条改动写成「文件 → 具体改成什么」的形式。'}
120
+ ${apply ? '你有写工具,直接落盘。**每次 `write_memory` / `archive_memory` 都必须带 `reason`**(一句话:为什么动这条、动成了什么)。这一趟的每次改动都会被记进「整理声明」,人要照着它核对。' : '你没有写工具,把每条改动写成「文件 → 具体改成什么 → 为什么」的形式。'}
113
121
 
114
122
  ## 阶段 4 — 修剪与索引
115
123
 
@@ -1,23 +1,33 @@
1
1
  /**
2
- * sage-mem dream — 交给 dream 模型的工具集,以及它们的 host 侧执行器。
2
+ * sage-mem autodream — 交给 autodream 模型的工具集,以及它们的 host 侧执行器。
3
3
  *
4
4
  * 这一层是**安全边界**,不是提示词。常见做法是「让模型随便请求、宿主逐个裁决」;
5
5
  * 这里换了个更硬的写法 —— **模型只能看见这几个工具**,
6
6
  * 而每个工具的宿主实现只认 memory 目录顶层的 `.md` 文件名。模型想越界
7
7
  * (写别的目录、动子目录、写非 md、路径穿越)不是「被拒绝」,是**没有这个能力**。
8
8
  *
9
- * 还有一条刻意的不对称:
9
+ * 还有两条刻意的不对称:
10
10
  * - 「只出报告」模式下,写工具**根本不注册** —— 不是让模型别写,是它没有手。
11
11
  * - 删除也不是删除:archive_memory 把文件移进 `archive/`,可人工捞回。
12
12
  * memory 目录不在任何版本控制下、unlink 没有回收站,所以不留真删这条路径。
13
13
  *
14
+ * **每一次写操作都必须留下「改了什么、为什么」**:
15
+ * - `write_memory` 的 `reason` **强烈建议**但不强制:缺了照样写入,声明里标成「未自述」
16
+ * 并用宿主能追溯到的信息(第几轮 / 操作 / 字节变化)顶替。**硬拒的代价是丢掉一次
17
+ * 本来正确的修正**,那比缺一句解释更糟(2026-10-01 由参考设计调研推翻硬拒方案)。
18
+ * - `archive_memory` 的 `reason` 仍**必填**:归档是不可逆的退役动作,必须有人能说清为什么。
19
+ * - 每次成功写入/归档都产出一条结构化 `change`(前后指纹 + 缘由 + 缘由来源 + 字节差),
20
+ * 由 `changes()` 交给上层落成「整理声明」。**回滚也靠它**:只有知道动过哪几个文件、
21
+ * 动之前长什么样,才谈得上把文件级回滚做准。
22
+ *
14
23
  * 模型与宿主对同一份数据的理解必须一致:`list_memory` 的字段顺序、
15
- * `write_memory` 的 frontmatter 规范都直接抄 sage-mem 的既有约定,不另造一套。
24
+ * `write_memory` 的 frontmatter 规范都沿用 sage-mem 的既有约定,不另造一套。
16
25
  */
17
26
 
18
27
  import { readFile, readdir, writeFile, rename, stat, mkdir } from 'node:fs/promises'
19
28
  import { join, basename } from 'node:path'
20
- import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './dream-audit.js'
29
+ import { createHash } from 'node:crypto'
30
+ import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
21
31
 
22
32
  /** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
23
33
  const READ_LIMIT = 20000
@@ -32,7 +42,7 @@ const INDEX_FILE = 'memory.md'
32
42
  /**
33
43
  * 非记忆条目:不进 list_memory。
34
44
  *
35
- * session-log.md 是追加型流水(本机已 390+ KB),不是一条记忆 —— 列出来既
45
+ * session-log.md 是追加型流水,不是一条记忆 —— 列出来既
36
46
  * 占掉模型一大块注意力,又诱导它去「整理」一份流水账。2026-09-27 冒烟测试
37
47
  * 逮到的就是这条:它出现在了 list_memory 的输出里。
38
48
  */
@@ -42,7 +52,7 @@ const NON_ENTRY_FILES = new Set(['session-log.md'])
42
52
  * 把一个模型给的文件名收敛成 memory 目录顶层的一个真实路径。
43
53
  *
44
54
  * 返回 null 表示「这个请求不合法」——调用方一律当成工具执行失败回给模型,
45
- * 不抛异常打断整个 dream(模型写错一次文件名不该毁掉整趟整理)。
55
+ * 不抛异常打断整个 autodream(模型写错一次文件名不该毁掉整趟整理)。
46
56
  *
47
57
  * @param {string} memoryDir — 记忆根目录
48
58
  * @param {unknown} raw — 模型给的 file 参数
@@ -60,8 +70,24 @@ export function safeMemoryPath(memoryDir, raw) {
60
70
  return join(memoryDir, base)
61
71
  }
62
72
 
63
- /** ISO 时间 → 本地 `YYYY-MM-DD HH:mm`(报告与列表里给人看)。 */
64
- function localStamp(ms) {
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) {
65
91
  const d = new Date(ms)
66
92
  const p = (n) => String(n).padStart(2, '0')
67
93
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
@@ -177,7 +203,7 @@ async function searchSessions(sessionsRoot, query, limit, sinceMs) {
177
203
  if (total > SEARCH_LIMIT || hits.length > limit) break
178
204
  }
179
205
  } catch {
180
- // 单个会话读不动就跳过:dream 不该因为一个坏文件整体失败。
206
+ // 单个会话读不动就跳过:autodream 不该因为一个坏文件整体失败。
181
207
  }
182
208
  if (total > SEARCH_LIMIT || hits.length > limit) break
183
209
  }
@@ -189,11 +215,11 @@ async function searchSessions(sessionsRoot, query, limit, sinceMs) {
189
215
  const str = (description) => ({ type: 'string', description })
190
216
 
191
217
  /**
192
- * 组装这一趟 dream 能用的工具定义。
218
+ * 组装这一趟 autodream 能用的工具定义。
193
219
  * @param {{apply: boolean, withSessions: boolean}} opts
194
220
  * @returns {Array<{name:string,description:string,parameters:object}>}
195
221
  */
196
- export function dreamToolSchemas(opts) {
222
+ export function autodreamToolSchemas(opts) {
197
223
  const tools = [
198
224
  {
199
225
  name: 'list_memory',
@@ -206,7 +232,7 @@ export function dreamToolSchemas(opts) {
206
232
  description: '读一条记忆的全文(frontmatter + 正文)。文件名要和 list_memory 返回的完全一致。',
207
233
  parameters: {
208
234
  type: 'object',
209
- properties: { file: str('记忆文件名,例如 feedback_autonomy-boundary.md') },
235
+ properties: { file: str('记忆文件名,例如 feedback_example.md') },
210
236
  required: ['file'],
211
237
  additionalProperties: false,
212
238
  },
@@ -239,12 +265,17 @@ export function dreamToolSchemas(opts) {
239
265
  {
240
266
  name: 'write_memory',
241
267
  description:
242
- '写入或整体覆盖一条记忆文件(frontmatter + 正文)。只允许 memory 目录顶层的 .md。整合新信息时优先改写已有文件,不要新建近似重复的文件。',
268
+ '写入或整体覆盖一条记忆文件(frontmatter + 正文)。只允许 memory 目录顶层的 .md。整合新信息时优先改写已有文件,不要新建近似重复的文件。**reason 请尽量填写**:这些改动会连同缘由一起写进整理声明,人要靠它判断这次自动整理干得对不对;缺了不会被拒绝,但声明里会把那条标成「未自述」。',
243
269
  parameters: {
244
270
  type: 'object',
245
271
  properties: {
246
- file: str('记忆文件名,例如 project_dsh-migration.md'),
272
+ file: str('记忆文件名,例如 project_example.md'),
247
273
  content: str('完整文件内容,必须以 --- 开头的 YAML frontmatter 起始'),
274
+ reason: str(
275
+ '强烈建议填。一句话说清为什么改这条、改了什么(例如「合并了重复的部署记录,删掉被推翻的旧路径」)。' +
276
+ '缺了**不会**被拒绝——改动照样生效——但整理声明会把这条标成「未自述」,用宿主能追溯到的信息' +
277
+ '(第几轮 / 什么操作 / 字节变化)顶替。硬拒的代价是丢掉一次本来正确的修正,那比缺一句解释更糟。',
278
+ ),
248
279
  },
249
280
  required: ['file', 'content'],
250
281
  additionalProperties: false,
@@ -269,24 +300,96 @@ export function dreamToolSchemas(opts) {
269
300
  return tools
270
301
  }
271
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
+
272
322
  /**
273
323
  * 造一个工具执行器。返回的 run() 永远解析成字符串(失败信息也回给模型),
274
- * 不抛异常 —— 模型拿到错误描述才能自己改,而不是让整趟 dream 崩掉。
324
+ * 不抛异常 —— 模型拿到错误描述才能自己改,而不是让整趟 autodream 崩掉。
275
325
  *
276
326
  * @param {{memoryDir:string, sessionsRoot:string, apply:boolean, withSessions:boolean}} opts
277
- * @returns {(name:string, args:object) => Promise<string>}
327
+ * @returns {{run:(name:string, args:object)=>Promise<string>, touchedFiles:()=>string[], changes:()=>object[], warnings:()=>string[]}}
278
328
  */
279
329
  export function createToolRunner(opts) {
280
330
  const { memoryDir, sessionsRoot, apply, withSessions } = opts
281
331
  let listCache = null
282
332
  let auditCache = null
283
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
+ }
284
371
 
285
372
  return {
286
- /** dream 报告要写「改了哪些文件」,由这里记录。 */
373
+ /** 报告要写「改了哪些文件」,由这里记录。 */
287
374
  touchedFiles() {
288
375
  return [...touched]
289
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
+ },
290
393
 
291
394
  async run(name, args) {
292
395
  const a = args && typeof args === 'object' ? args : {}
@@ -301,8 +404,10 @@ export function createToolRunner(opts) {
301
404
  return `记忆目录:${memoryDir}\n共 ${listCache.length} 条(按修改时间由新到旧):\n\n${lines.join('\n')}`
302
405
  }
303
406
  case 'read_memory': {
304
- const full = safeMemoryPath(memoryDir, a.file)
305
- if (!full) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
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)))
306
411
  const content = await readFile(full, 'utf8').catch((err) => {
307
412
  if (err?.code === 'ENOENT') return null
308
413
  throw err
@@ -318,29 +423,55 @@ export function createToolRunner(opts) {
318
423
  return formatAudit(auditCache, 20)
319
424
  }
320
425
  case 'search_sessions': {
321
- if (!withSessions) return '错误:本次 dream 的输入源不包含会话记录,search_sessions 不可用。'
426
+ if (!withSessions) return '错误:本次 autodream 的输入源不包含会话记录,search_sessions 不可用。'
322
427
  const lim = Number.isFinite(a.limit) ? Math.max(1, Math.min(60, Number(a.limit))) : 20
323
428
  return await searchSessions(sessionsRoot, a.query, lim, 0)
324
429
  }
325
430
  case 'write_memory': {
326
431
  if (!apply) return '错误:当前是「只出报告」模式,没有写权限。请把改动写进你的最终回复。'
327
- const full = safeMemoryPath(memoryDir, a.file)
328
- if (!full) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
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)))
329
437
  if (typeof a.content !== 'string' || !a.content.trim()) return '错误:content 为空。'
330
438
  if (!/^---\r?\n/.test(a.content)) {
331
439
  return '错误:内容必须以 `---` 开头的 YAML frontmatter 起始。参考 list_memory 里已有文件的写法。'
332
440
  }
333
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)
334
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
+ )
335
460
  touched.push(basename(full))
336
461
  listCache = null
337
462
  auditCache = null
338
- return `已写入 ${basename(full)}(${a.content.length} 字符)。`
463
+ return `已写入 ${basename(full)}(${a.content.length} 字符)${before.exist ? '' : '(新建)'}。`
339
464
  }
340
465
  case 'archive_memory': {
341
466
  if (!apply) return '错误:当前是「只出报告」模式,没有归档权限。请把建议写进你的最终回复。'
342
- const full = safeMemoryPath(memoryDir, a.file)
343
- if (!full) return `错误:文件名不合法:${String(a.file)}`
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
+ }
344
475
  const exists = await stat(full).catch(() => null)
345
476
  if (!exists) return `错误:没有这个文件:${basename(full)}`
346
477
  const archiveDir = join(memoryDir, ARCHIVE_DIR)
@@ -348,11 +479,13 @@ export function createToolRunner(opts) {
348
479
  const target = join(archiveDir, basename(full))
349
480
  const clash = await stat(target).catch(() => null)
350
481
  if (clash) return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
482
+ const before = await fileFingerprint(full)
351
483
  await rename(full, target)
484
+ changeRecord('archive', basename(full), reason, before, { exist: false, bytes: 0, sha256: null })
352
485
  touched.push(`${basename(full)} → archive/`)
353
486
  listCache = null
354
487
  auditCache = null
355
- return `已归档 ${basename(full)}(原因:${String(a.reason || '未写')})。`
488
+ return `已归档 ${basename(full)}(原因:${reason})。`
356
489
  }
357
490
  default:
358
491
  return `错误:没有这个工具:${name}`