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.
- package/README.md +282 -242
- package/lib/{dream-audit.js → autodream-audit.js} +31 -13
- package/lib/{dream-prompt.js → autodream-prompt.js} +22 -14
- package/lib/{dream-tools.js → autodream-tools.js} +159 -26
- package/lib/autodream.js +1722 -0
- package/lib/client.js +460 -142
- package/lib/index.js +219 -67
- package/lib/typert.host.js +182 -54
- package/package.json +89 -87
- package/lib/dream.js +0 -886
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sage-mem
|
|
2
|
+
* sage-mem autodream — 记忆目录审计(七查)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
* **纯只读**:不修任何东西。修哪条、怎么修,由
|
|
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
|
|
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
|
-
|
|
135
|
+
try {
|
|
136
|
+
contents.set(f, await readFile(join(dir, f), 'utf8'))
|
|
137
|
+
} catch {
|
|
138
|
+
unreadable.push(f)
|
|
139
|
+
}
|
|
128
140
|
}
|
|
129
|
-
|
|
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
|
|
134
|
-
for (const f of
|
|
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
|
|
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
|
-
* 把审计结果压成给模型看的短文本(
|
|
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
|
|
2
|
+
* sage-mem autodream — 提示词。
|
|
3
3
|
*
|
|
4
4
|
* 四阶段结构:定位 → 收集 → 整合 → 修剪与索引。两个非显然的取舍:
|
|
5
5
|
*
|
|
6
|
-
* 1.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 否则模型会照着「看起来像」的方式写坏。
|
|
6
|
+
* 1. **写记忆的规范必须随 prompt 自带。** autodream 是一次独立调用,带不上外部的
|
|
7
|
+
* 技能文档。所以四类前缀、`type` 一致性、YAML 两个静默陷阱、受保护文件名这些
|
|
8
|
+
* **必须**出现在 prompt 里,否则模型会照着「看起来像」的方式写坏。
|
|
10
9
|
* 2. **按模式分叉。** 「只出报告」不是靠一句「请不要写」约束的——那一趟根本
|
|
11
|
-
* 没有写工具(见
|
|
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
|
|
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
|
-
|
|
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
|
|
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 `#
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
* 不抛异常打断整个
|
|
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
|
-
/**
|
|
64
|
-
|
|
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
|
-
// 单个会话读不动就跳过:
|
|
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
|
-
* 组装这一趟
|
|
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
|
|
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('记忆文件名,例如
|
|
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('记忆文件名,例如
|
|
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
|
-
* 不抛异常 —— 模型拿到错误描述才能自己改,而不是让整趟
|
|
324
|
+
* 不抛异常 —— 模型拿到错误描述才能自己改,而不是让整趟 autodream 崩掉。
|
|
275
325
|
*
|
|
276
326
|
* @param {{memoryDir:string, sessionsRoot:string, apply:boolean, withSessions:boolean}} opts
|
|
277
|
-
* @returns {(name:string, args:object)
|
|
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
|
-
/**
|
|
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
|
|
305
|
-
if (!
|
|
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 '错误:本次
|
|
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
|
|
328
|
-
if (!
|
|
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
|
|
343
|
-
if (!
|
|
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)}(原因:${
|
|
488
|
+
return `已归档 ${basename(full)}(原因:${reason})。`
|
|
356
489
|
}
|
|
357
490
|
default:
|
|
358
491
|
return `错误:没有这个工具:${name}`
|