sage-mem 0.6.0 → 0.6.2

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,366 +1,365 @@
1
- /**
2
- * sage-mem dream — 交给 dream 模型的工具集,以及它们的 host 侧执行器。
3
- *
4
- * 这一层是**安全边界**,不是提示词。Claude Code 的对应物是
5
- * `createAutoMemCanUseTool(memoryRoot)`:fork 出来的子代理什么都能请求,
6
- * 由宿主逐个裁决。这里换了个更硬的写法 —— **模型只能看见这几个工具**,
7
- * 而每个工具的宿主实现只认 memory 目录顶层的 `.md` 文件名。模型想越界
8
- * (写别的目录、动子目录、写非 md、路径穿越)不是「被拒绝」,是**没有这个能力**。
9
- *
10
- * 还有一条刻意的不对称:
11
- * - 「只出报告」模式下,写工具**根本不注册** —— 不是让模型别写,是它没有手。
12
- * - 删除也不是删除:archive_memory 把文件移进 `archive/`,可人工捞回。
13
- * memory 目录不在任何版本控制下、unlink 没有回收站,所以不留真删这条路径。
14
- *
15
- * 模型与宿主对同一份数据的理解必须一致:`list_memory` 的字段顺序、
16
- * `write_memory` 的 frontmatter 规范都直接抄 sage-mem 的既有约定,不另造一套。
17
- */
18
-
19
- import { readFile, readdir, writeFile, rename, stat, mkdir } from 'node:fs/promises'
20
- import { join, basename } from 'node:path'
21
- import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './dream-audit.js'
22
-
23
- /** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
24
- const READ_LIMIT = 20000
25
- /** list_memory 里每条描述保留多少字。 */
26
- const LIST_DESC_CHARS = 70
27
- /** search_sessions 单次返回上限(字符)。 */
28
- const SEARCH_LIMIT = 8000
29
- /** 归档区目录名(相对 memory 根)。 */
30
- const ARCHIVE_DIR = 'archive'
31
- /** 索引文件名:它在列表里要单独标注,免得模型当成一条普通记忆去改。 */
32
- const INDEX_FILE = 'memory.md'
33
- /**
34
- * 非记忆条目:不进 list_memory。
35
- *
36
- * session-log.md 是追加型流水(本机已 390+ KB),不是一条记忆 —— 列出来既
37
- * 占掉模型一大块注意力,又诱导它去「整理」一份流水账。2026-09-27 冒烟测试
38
- * 逮到的就是这条:它出现在了 list_memory 的输出里。
39
- */
40
- const NON_ENTRY_FILES = new Set(['session-log.md'])
41
-
42
- /**
43
- * 把一个模型给的文件名收敛成 memory 目录顶层的一个真实路径。
44
- *
45
- * 返回 null 表示「这个请求不合法」——调用方一律当成工具执行失败回给模型,
46
- * 不抛异常打断整个 dream(模型写错一次文件名不该毁掉整趟整理)。
47
- *
48
- * @param {string} memoryDir — 记忆根目录
49
- * @param {unknown} raw — 模型给的 file 参数
50
- * @returns {string|null} 绝对路径,或 null
51
- */
52
- export function safeMemoryPath(memoryDir, raw) {
53
- if (typeof raw !== 'string') return null
54
- const trimmed = raw.trim()
55
- if (!trimmed) return null
56
- // 显式拒绝任何路径分隔与上跳:basename 只做兜底,先拒绝更清楚。
57
- if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) return null
58
- if (!/\.md$/i.test(trimmed)) return null
59
- const base = basename(trimmed)
60
- if (base.startsWith('.')) return null
61
- return join(memoryDir, base)
62
- }
63
-
64
- /** ISO 时间 → 本地 `YYYY-MM-DD HH:mm`(报告与列表里给人看)。 */
65
- function localStamp(ms) {
66
- const d = new Date(ms)
67
- const p = (n) => String(n).padStart(2, '0')
68
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
69
- }
70
-
71
- /** 距今多少天,给模型一个人话尺度。 */
72
- function ageDays(ms) {
73
- return Math.round(((Date.now() - ms) / 86400000) * 10) / 10
74
- }
75
-
76
- /**
77
- * 扫记忆目录,产出列表用的轻量元数据(不读全文)。
78
- * @param {string} memoryDir
79
- * @returns {Promise<Array<object>>}
80
- */
81
- async function scanEntries(memoryDir) {
82
- let entries
83
- try {
84
- entries = await readdir(memoryDir, { withFileTypes: true })
85
- } catch (err) {
86
- if (err?.code === 'ENOENT') return []
87
- throw err
88
- }
89
- const names = entries
90
- .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !NON_ENTRY_FILES.has(e.name.toLowerCase()))
91
- .map((e) => e.name)
92
- const out = []
93
- for (const name of names) {
94
- const full = join(memoryDir, name)
95
- try {
96
- const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
97
- const meta = parseFrontmatterLite(content)
98
- const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '')
99
- const h1 = body.match(/^#\s+(.+)$/m)
100
- out.push({
101
- file: name,
102
- // 索引不是一条记忆:标出来,否则模型会看到「MEMORY.md · reference」而困惑。
103
- type: name.toLowerCase() === INDEX_FILE ? '索引' : meta.type || 'reference',
104
- bytes: info.size,
105
- mtimeMs: info.mtimeMs,
106
- title: (h1 ? h1[1] : meta.name || '').trim(),
107
- description: meta.description || '',
108
- })
109
- } catch (err) {
110
- if (err?.code !== 'ENOENT') out.push({ file: name, type: '?', bytes: 0, mtimeMs: 0, title: '', description: `(读取失败: ${err?.message ?? err})` })
111
- }
112
- }
113
- out.sort((a, b) => b.mtimeMs - a.mtimeMs)
114
- return out
115
- }
116
-
117
- /**
118
- * 在 DSH 会话记录里做定向搜索。
119
- *
120
- * 会话正文是 zstd 压缩的(`session.v4.jsonl.zstd`),Node 24 的
121
- * `zlib.zstdDecompress` 能直接解 —— 不需要外部 zstd 可执行文件。
122
- * 只按 mtime 从新到旧扫,命中够数就停:这是「找昨天的报错原文」这类
123
- * 定向问题的用法,不是全量索引。
124
- *
125
- * @param {string} sessionsRoot — `$DSH_HOME/sessions`
126
- * @param {string} query
127
- * @param {number} limit
128
- * @param {number} sinceMs — 只看这个时间之后改过的会话
129
- * @returns {Promise<string>}
130
- */
131
- async function searchSessions(sessionsRoot, query, limit, sinceMs) {
132
- if (!sessionsRoot) return '(未配置 sessions 目录,无法搜索会话记录)'
133
- const needle = String(query || '').toLowerCase()
134
- if (needle.length < 2) return '(关键词太短,至少 2 个字符)'
135
-
136
- let zlib
137
- try {
138
- zlib = await import('node:zlib')
139
- } catch {
140
- return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
141
- }
142
- if (typeof zlib.zstdDecompress !== 'function') {
143
- return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
144
- }
145
-
146
- let dirs
147
- try {
148
- const projects = await readdir(sessionsRoot, { withFileTypes: true })
149
- dirs = []
150
- for (const p of projects) {
151
- if (!p.isDirectory()) continue
152
- const projPath = join(sessionsRoot, p.name)
153
- const sessions = await readdir(projPath, { withFileTypes: true }).catch(() => [])
154
- for (const s of sessions) {
155
- if (!s.isDirectory()) continue
156
- const file = join(projPath, s.name, 'session.v4.jsonl.zstd')
157
- const info = await stat(file).catch(() => null)
158
- if (info && info.mtimeMs >= sinceMs) dirs.push({ file, mtimeMs: info.mtimeMs })
159
- }
160
- }
161
- } catch (err) {
162
- return `(读取 sessions 目录失败:${err?.message ?? err})`
163
- }
164
-
165
- dirs.sort((a, b) => b.mtimeMs - a.mtimeMs)
166
- const hits = []
167
- let total = 0
168
- for (const d of dirs.slice(0, 40)) {
169
- try {
170
- const raw = await readFile(d.file)
171
- const text = zlib.zstdDecompress(raw).toString('utf8')
172
- const lines = text.split(/\r?\n/)
173
- for (const line of lines) {
174
- if (line.length < 8) continue
175
- if (!line.toLowerCase().includes(needle)) continue
176
- hits.push(`[${localStamp(d.mtimeMs)}] ${line.length > 500 ? line.slice(0, 500) + '…' : line}`)
177
- total += line.length
178
- if (total > SEARCH_LIMIT || hits.length > limit) break
179
- }
180
- } catch {
181
- // 单个会话读不动就跳过:dream 不该因为一个坏文件整体失败。
182
- }
183
- if (total > SEARCH_LIMIT || hits.length > limit) break
184
- }
185
- if (!hits.length) return `(在 ${sinceMs ? '上次整理之后' : '全部'}的会话记录里没找到含「${query}」的内容)`
186
- return `命中 ${hits.length} 段(按 mtime 由新到旧):\n\n${hits.join('\n')}`
187
- }
188
-
189
- /** JSON Schema 片段:工具参数。 */
190
- const str = (description) => ({ type: 'string', description })
191
-
192
- /**
193
- * 组装这一趟 dream 能用的工具定义。
194
- * @param {{apply: boolean, withSessions: boolean}} opts
195
- * @returns {Array<{name:string,description:string,parameters:object}>}
196
- */
197
- export function dreamToolSchemas(opts) {
198
- const tools = [
199
- {
200
- name: 'list_memory',
201
- description:
202
- '列出记忆目录里的全部条目(文件名 / 类型 / 体积 / 距今多少天 / 描述)。这是第一步:先看清已有什么,再决定改哪一条,避免造出重复文件。',
203
- parameters: { type: 'object', properties: {}, additionalProperties: false },
204
- },
205
- {
206
- name: 'read_memory',
207
- description: '读一条记忆的全文(frontmatter + 正文)。文件名要和 list_memory 返回的完全一致。',
208
- parameters: {
209
- type: 'object',
210
- properties: { file: str('记忆文件名,例如 feedback_autonomy-boundary.md') },
211
- required: ['file'],
212
- additionalProperties: false,
213
- },
214
- },
215
- {
216
- name: 'audit_memory',
217
- description:
218
- '对记忆目录跑一遍结构审计:索引悬空 / 漏索引 / type 与文件名前缀不一致 / CRLF / 双链断链 / frontmatter 里被 YAML 当注释吞掉的「空格+#」。只读,不修改任何文件。',
219
- parameters: { type: 'object', properties: {}, additionalProperties: false },
220
- },
221
- ]
222
- if (opts.withSessions) {
223
- tools.push({
224
- name: 'search_sessions',
225
- description:
226
- '在 DSH 会话记录(zstd 压缩的 jsonl)里做定向关键词搜索,由新到旧。只在确实需要某个具体细节时才用(例如某次报错原文),不要用它通读会话。',
227
- parameters: {
228
- type: 'object',
229
- properties: {
230
- query: str('要搜的关键词,至少 2 个字符'),
231
- limit: { type: 'number', description: '最多返回多少段,默认 20' },
232
- },
233
- required: ['query'],
234
- additionalProperties: false,
235
- },
236
- })
237
- }
238
- if (opts.apply) {
239
- tools.push(
240
- {
241
- name: 'write_memory',
242
- description:
243
- '写入或整体覆盖一条记忆文件(frontmatter + 正文)。只允许 memory 目录顶层的 .md。整合新信息时优先改写已有文件,不要新建近似重复的文件。',
244
- parameters: {
245
- type: 'object',
246
- properties: {
247
- file: str('记忆文件名,例如 project_dsh-migration.md'),
248
- content: str('完整文件内容,必须以 --- 开头的 YAML frontmatter 起始'),
249
- },
250
- required: ['file', 'content'],
251
- additionalProperties: false,
252
- },
253
- },
254
- {
255
- name: 'archive_memory',
256
- description:
257
- '把一条记忆移进 archive/ 子目录(等于退役:不再参与检索与星图,但文件仍在、可人工捞回)。用于被推翻的、或被别的记忆取代的条目。没有删除功能——归档是这里能造成的最强后果。',
258
- parameters: {
259
- type: 'object',
260
- properties: {
261
- file: str('要归档的记忆文件名'),
262
- reason: str('一句话说明为什么归档'),
263
- },
264
- required: ['file', 'reason'],
265
- additionalProperties: false,
266
- },
267
- },
268
- )
269
- }
270
- return tools
271
- }
272
-
273
- /**
274
- * 造一个工具执行器。返回的 run() 永远解析成字符串(失败信息也回给模型),
275
- * 不抛异常 —— 模型拿到错误描述才能自己改,而不是让整趟 dream 崩掉。
276
- *
277
- * @param {{memoryDir:string, sessionsRoot:string, apply:boolean, withSessions:boolean}} opts
278
- * @returns {(name:string, args:object) => Promise<string>}
279
- */
280
- export function createToolRunner(opts) {
281
- const { memoryDir, sessionsRoot, apply, withSessions } = opts
282
- let listCache = null
283
- let auditCache = null
284
- const touched = []
285
-
286
- return {
287
- /** dream 报告要写「改了哪些文件」,由这里记录。 */
288
- touchedFiles() {
289
- return [...touched]
290
- },
291
-
292
- async run(name, args) {
293
- const a = args && typeof args === 'object' ? args : {}
294
- try {
295
- switch (name) {
296
- case 'list_memory': {
297
- if (!listCache) listCache = await scanEntries(memoryDir)
298
- const lines = listCache.map(
299
- (e) =>
300
- `- ${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) : ''}`,
301
- )
302
- return `记忆目录:${memoryDir}\n共 ${listCache.length} 条(按修改时间由新到旧):\n\n${lines.join('\n')}`
303
- }
304
- case 'read_memory': {
305
- const full = safeMemoryPath(memoryDir, a.file)
306
- if (!full) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
307
- const content = await readFile(full, 'utf8').catch((err) => {
308
- if (err?.code === 'ENOENT') return null
309
- throw err
310
- })
311
- if (content === null) return `错误:没有这个文件:${basename(full)}`
312
- if (content.length > READ_LIMIT) {
313
- return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符)`
314
- }
315
- return content
316
- }
317
- case 'audit_memory': {
318
- if (!auditCache) auditCache = await auditMemoryDir(memoryDir)
319
- return formatAudit(auditCache, 20)
320
- }
321
- case 'search_sessions': {
322
- if (!withSessions) return '错误:本次 dream 的输入源不包含会话记录,search_sessions 不可用。'
323
- const lim = Number.isFinite(a.limit) ? Math.max(1, Math.min(60, Number(a.limit))) : 20
324
- return await searchSessions(sessionsRoot, a.query, lim, 0)
325
- }
326
- case 'write_memory': {
327
- if (!apply) return '错误:当前是「只出报告」模式,没有写权限。请把改动写进你的最终回复。'
328
- const full = safeMemoryPath(memoryDir, a.file)
329
- if (!full) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
330
- if (typeof a.content !== 'string' || !a.content.trim()) return '错误:content 为空。'
331
- if (!/^---\r?\n/.test(a.content)) {
332
- return '错误:内容必须以 `---` 开头的 YAML frontmatter 起始。参考 list_memory 里已有文件的写法。'
333
- }
334
- if (a.content.length > 512 * 1024) return `错误:内容过大(${a.content.length} 字符)。`
335
- await writeFile(full, a.content, 'utf8')
336
- touched.push(basename(full))
337
- listCache = null
338
- auditCache = null
339
- return `已写入 ${basename(full)}(${a.content.length} 字符)。`
340
- }
341
- case 'archive_memory': {
342
- if (!apply) return '错误:当前是「只出报告」模式,没有归档权限。请把建议写进你的最终回复。'
343
- const full = safeMemoryPath(memoryDir, a.file)
344
- if (!full) return `错误:文件名不合法:${String(a.file)}`
345
- const exists = await stat(full).catch(() => null)
346
- if (!exists) return `错误:没有这个文件:${basename(full)}`
347
- const archiveDir = join(memoryDir, ARCHIVE_DIR)
348
- await mkdir(archiveDir, { recursive: true })
349
- const target = join(archiveDir, basename(full))
350
- const clash = await stat(target).catch(() => null)
351
- if (clash) return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
352
- await rename(full, target)
353
- touched.push(`${basename(full)} → archive/`)
354
- listCache = null
355
- auditCache = null
356
- return `已归档 ${basename(full)}(原因:${String(a.reason || '未写')})。`
357
- }
358
- default:
359
- return `错误:没有这个工具:${name}`
360
- }
361
- } catch (err) {
362
- return `工具执行失败:${err?.message ?? String(err)}`
363
- }
364
- },
365
- }
366
- }
1
+ /**
2
+ * sage-mem dream — 交给 dream 模型的工具集,以及它们的 host 侧执行器。
3
+ *
4
+ * 这一层是**安全边界**,不是提示词。常见做法是「让模型随便请求、宿主逐个裁决」;
5
+ * 这里换了个更硬的写法 —— **模型只能看见这几个工具**,
6
+ * 而每个工具的宿主实现只认 memory 目录顶层的 `.md` 文件名。模型想越界
7
+ * (写别的目录、动子目录、写非 md、路径穿越)不是「被拒绝」,是**没有这个能力**。
8
+ *
9
+ * 还有一条刻意的不对称:
10
+ * - 「只出报告」模式下,写工具**根本不注册** —— 不是让模型别写,是它没有手。
11
+ * - 删除也不是删除:archive_memory 把文件移进 `archive/`,可人工捞回。
12
+ * memory 目录不在任何版本控制下、unlink 没有回收站,所以不留真删这条路径。
13
+ *
14
+ * 模型与宿主对同一份数据的理解必须一致:`list_memory` 的字段顺序、
15
+ * `write_memory` 的 frontmatter 规范都直接抄 sage-mem 的既有约定,不另造一套。
16
+ */
17
+
18
+ import { readFile, readdir, writeFile, rename, stat, mkdir } from 'node:fs/promises'
19
+ import { join, basename } from 'node:path'
20
+ import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './dream-audit.js'
21
+
22
+ /** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
23
+ const READ_LIMIT = 20000
24
+ /** list_memory 里每条描述保留多少字。 */
25
+ const LIST_DESC_CHARS = 70
26
+ /** search_sessions 单次返回上限(字符)。 */
27
+ const SEARCH_LIMIT = 8000
28
+ /** 归档区目录名(相对 memory 根)。 */
29
+ const ARCHIVE_DIR = 'archive'
30
+ /** 索引文件名:它在列表里要单独标注,免得模型当成一条普通记忆去改。 */
31
+ const INDEX_FILE = 'memory.md'
32
+ /**
33
+ * 非记忆条目:不进 list_memory。
34
+ *
35
+ * session-log.md 是追加型流水(本机已 390+ KB),不是一条记忆 —— 列出来既
36
+ * 占掉模型一大块注意力,又诱导它去「整理」一份流水账。2026-09-27 冒烟测试
37
+ * 逮到的就是这条:它出现在了 list_memory 的输出里。
38
+ */
39
+ const NON_ENTRY_FILES = new Set(['session-log.md'])
40
+
41
+ /**
42
+ * 把一个模型给的文件名收敛成 memory 目录顶层的一个真实路径。
43
+ *
44
+ * 返回 null 表示「这个请求不合法」——调用方一律当成工具执行失败回给模型,
45
+ * 不抛异常打断整个 dream(模型写错一次文件名不该毁掉整趟整理)。
46
+ *
47
+ * @param {string} memoryDir — 记忆根目录
48
+ * @param {unknown} raw — 模型给的 file 参数
49
+ * @returns {string|null} 绝对路径,或 null
50
+ */
51
+ export function safeMemoryPath(memoryDir, raw) {
52
+ if (typeof raw !== 'string') return null
53
+ const trimmed = raw.trim()
54
+ if (!trimmed) return null
55
+ // 显式拒绝任何路径分隔与上跳:basename 只做兜底,先拒绝更清楚。
56
+ if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) return null
57
+ if (!/\.md$/i.test(trimmed)) return null
58
+ const base = basename(trimmed)
59
+ if (base.startsWith('.')) return null
60
+ return join(memoryDir, base)
61
+ }
62
+
63
+ /** ISO 时间 → 本地 `YYYY-MM-DD HH:mm`(报告与列表里给人看)。 */
64
+ function localStamp(ms) {
65
+ const d = new Date(ms)
66
+ const p = (n) => String(n).padStart(2, '0')
67
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
68
+ }
69
+
70
+ /** 距今多少天,给模型一个人话尺度。 */
71
+ function ageDays(ms) {
72
+ return Math.round(((Date.now() - ms) / 86400000) * 10) / 10
73
+ }
74
+
75
+ /**
76
+ * 扫记忆目录,产出列表用的轻量元数据(不读全文)。
77
+ * @param {string} memoryDir
78
+ * @returns {Promise<Array<object>>}
79
+ */
80
+ async function scanEntries(memoryDir) {
81
+ let entries
82
+ try {
83
+ entries = await readdir(memoryDir, { withFileTypes: true })
84
+ } catch (err) {
85
+ if (err?.code === 'ENOENT') return []
86
+ throw err
87
+ }
88
+ const names = entries
89
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !NON_ENTRY_FILES.has(e.name.toLowerCase()))
90
+ .map((e) => e.name)
91
+ const out = []
92
+ for (const name of names) {
93
+ const full = join(memoryDir, name)
94
+ try {
95
+ const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
96
+ const meta = parseFrontmatterLite(content)
97
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '')
98
+ const h1 = body.match(/^#\s+(.+)$/m)
99
+ out.push({
100
+ file: name,
101
+ // 索引不是一条记忆:标出来,否则模型会看到「MEMORY.md · reference」而困惑。
102
+ type: name.toLowerCase() === INDEX_FILE ? '索引' : meta.type || 'reference',
103
+ bytes: info.size,
104
+ mtimeMs: info.mtimeMs,
105
+ title: (h1 ? h1[1] : meta.name || '').trim(),
106
+ description: meta.description || '',
107
+ })
108
+ } catch (err) {
109
+ if (err?.code !== 'ENOENT') out.push({ file: name, type: '?', bytes: 0, mtimeMs: 0, title: '', description: `(读取失败: ${err?.message ?? err})` })
110
+ }
111
+ }
112
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs)
113
+ return out
114
+ }
115
+
116
+ /**
117
+ * 在 DSH 会话记录里做定向搜索。
118
+ *
119
+ * 会话正文是 zstd 压缩的(`session.v4.jsonl.zstd`),Node 24 的
120
+ * `zlib.zstdDecompress` 能直接解 —— 不需要外部 zstd 可执行文件。
121
+ * 只按 mtime 从新到旧扫,命中够数就停:这是「找昨天的报错原文」这类
122
+ * 定向问题的用法,不是全量索引。
123
+ *
124
+ * @param {string} sessionsRoot — `$DSH_HOME/sessions`
125
+ * @param {string} query
126
+ * @param {number} limit
127
+ * @param {number} sinceMs — 只看这个时间之后改过的会话
128
+ * @returns {Promise<string>}
129
+ */
130
+ async function searchSessions(sessionsRoot, query, limit, sinceMs) {
131
+ if (!sessionsRoot) return '(未配置 sessions 目录,无法搜索会话记录)'
132
+ const needle = String(query || '').toLowerCase()
133
+ if (needle.length < 2) return '(关键词太短,至少 2 个字符)'
134
+
135
+ let zlib
136
+ try {
137
+ zlib = await import('node:zlib')
138
+ } catch {
139
+ return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
140
+ }
141
+ if (typeof zlib.zstdDecompress !== 'function') {
142
+ return '(当前 Node 不支持 zstd 解压,无法读取会话记录)'
143
+ }
144
+
145
+ let dirs
146
+ try {
147
+ const projects = await readdir(sessionsRoot, { withFileTypes: true })
148
+ dirs = []
149
+ for (const p of projects) {
150
+ if (!p.isDirectory()) continue
151
+ const projPath = join(sessionsRoot, p.name)
152
+ const sessions = await readdir(projPath, { withFileTypes: true }).catch(() => [])
153
+ for (const s of sessions) {
154
+ if (!s.isDirectory()) continue
155
+ const file = join(projPath, s.name, 'session.v4.jsonl.zstd')
156
+ const info = await stat(file).catch(() => null)
157
+ if (info && info.mtimeMs >= sinceMs) dirs.push({ file, mtimeMs: info.mtimeMs })
158
+ }
159
+ }
160
+ } catch (err) {
161
+ return `(读取 sessions 目录失败:${err?.message ?? err})`
162
+ }
163
+
164
+ dirs.sort((a, b) => b.mtimeMs - a.mtimeMs)
165
+ const hits = []
166
+ let total = 0
167
+ for (const d of dirs.slice(0, 40)) {
168
+ try {
169
+ const raw = await readFile(d.file)
170
+ const text = zlib.zstdDecompress(raw).toString('utf8')
171
+ const lines = text.split(/\r?\n/)
172
+ for (const line of lines) {
173
+ if (line.length < 8) continue
174
+ if (!line.toLowerCase().includes(needle)) continue
175
+ hits.push(`[${localStamp(d.mtimeMs)}] ${line.length > 500 ? line.slice(0, 500) + '…' : line}`)
176
+ total += line.length
177
+ if (total > SEARCH_LIMIT || hits.length > limit) break
178
+ }
179
+ } catch {
180
+ // 单个会话读不动就跳过:dream 不该因为一个坏文件整体失败。
181
+ }
182
+ if (total > SEARCH_LIMIT || hits.length > limit) break
183
+ }
184
+ if (!hits.length) return `(在 ${sinceMs ? '上次整理之后' : '全部'}的会话记录里没找到含「${query}」的内容)`
185
+ return `命中 ${hits.length} 段(按 mtime 由新到旧):\n\n${hits.join('\n')}`
186
+ }
187
+
188
+ /** JSON Schema 片段:工具参数。 */
189
+ const str = (description) => ({ type: 'string', description })
190
+
191
+ /**
192
+ * 组装这一趟 dream 能用的工具定义。
193
+ * @param {{apply: boolean, withSessions: boolean}} opts
194
+ * @returns {Array<{name:string,description:string,parameters:object}>}
195
+ */
196
+ export function dreamToolSchemas(opts) {
197
+ const tools = [
198
+ {
199
+ name: 'list_memory',
200
+ description:
201
+ '列出记忆目录里的全部条目(文件名 / 类型 / 体积 / 距今多少天 / 描述)。这是第一步:先看清已有什么,再决定改哪一条,避免造出重复文件。',
202
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
203
+ },
204
+ {
205
+ name: 'read_memory',
206
+ description: '读一条记忆的全文(frontmatter + 正文)。文件名要和 list_memory 返回的完全一致。',
207
+ parameters: {
208
+ type: 'object',
209
+ properties: { file: str('记忆文件名,例如 feedback_autonomy-boundary.md') },
210
+ required: ['file'],
211
+ additionalProperties: false,
212
+ },
213
+ },
214
+ {
215
+ name: 'audit_memory',
216
+ description:
217
+ '对记忆目录跑一遍结构审计:索引悬空 / 漏索引 / type 与文件名前缀不一致 / CRLF / 双链断链 / frontmatter 里被 YAML 当注释吞掉的「空格+#」。只读,不修改任何文件。',
218
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
219
+ },
220
+ ]
221
+ if (opts.withSessions) {
222
+ tools.push({
223
+ name: 'search_sessions',
224
+ description:
225
+ '在 DSH 会话记录(zstd 压缩的 jsonl)里做定向关键词搜索,由新到旧。只在确实需要某个具体细节时才用(例如某次报错原文),不要用它通读会话。',
226
+ parameters: {
227
+ type: 'object',
228
+ properties: {
229
+ query: str('要搜的关键词,至少 2 个字符'),
230
+ limit: { type: 'number', description: '最多返回多少段,默认 20' },
231
+ },
232
+ required: ['query'],
233
+ additionalProperties: false,
234
+ },
235
+ })
236
+ }
237
+ if (opts.apply) {
238
+ tools.push(
239
+ {
240
+ name: 'write_memory',
241
+ description:
242
+ '写入或整体覆盖一条记忆文件(frontmatter + 正文)。只允许 memory 目录顶层的 .md。整合新信息时优先改写已有文件,不要新建近似重复的文件。',
243
+ parameters: {
244
+ type: 'object',
245
+ properties: {
246
+ file: str('记忆文件名,例如 project_dsh-migration.md'),
247
+ content: str('完整文件内容,必须以 --- 开头的 YAML frontmatter 起始'),
248
+ },
249
+ required: ['file', 'content'],
250
+ additionalProperties: false,
251
+ },
252
+ },
253
+ {
254
+ name: 'archive_memory',
255
+ description:
256
+ '把一条记忆移进 archive/ 子目录(等于退役:不再参与检索与星图,但文件仍在、可人工捞回)。用于被推翻的、或被别的记忆取代的条目。没有删除功能——归档是这里能造成的最强后果。',
257
+ parameters: {
258
+ type: 'object',
259
+ properties: {
260
+ file: str('要归档的记忆文件名'),
261
+ reason: str('一句话说明为什么归档'),
262
+ },
263
+ required: ['file', 'reason'],
264
+ additionalProperties: false,
265
+ },
266
+ },
267
+ )
268
+ }
269
+ return tools
270
+ }
271
+
272
+ /**
273
+ * 造一个工具执行器。返回的 run() 永远解析成字符串(失败信息也回给模型),
274
+ * 不抛异常 —— 模型拿到错误描述才能自己改,而不是让整趟 dream 崩掉。
275
+ *
276
+ * @param {{memoryDir:string, sessionsRoot:string, apply:boolean, withSessions:boolean}} opts
277
+ * @returns {(name:string, args:object) => Promise<string>}
278
+ */
279
+ export function createToolRunner(opts) {
280
+ const { memoryDir, sessionsRoot, apply, withSessions } = opts
281
+ let listCache = null
282
+ let auditCache = null
283
+ const touched = []
284
+
285
+ return {
286
+ /** dream 报告要写「改了哪些文件」,由这里记录。 */
287
+ touchedFiles() {
288
+ return [...touched]
289
+ },
290
+
291
+ async run(name, args) {
292
+ const a = args && typeof args === 'object' ? args : {}
293
+ try {
294
+ switch (name) {
295
+ case 'list_memory': {
296
+ if (!listCache) listCache = await scanEntries(memoryDir)
297
+ const lines = listCache.map(
298
+ (e) =>
299
+ `- ${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) : ''}`,
300
+ )
301
+ return `记忆目录:${memoryDir}\n共 ${listCache.length} 条(按修改时间由新到旧):\n\n${lines.join('\n')}`
302
+ }
303
+ case 'read_memory': {
304
+ const full = safeMemoryPath(memoryDir, a.file)
305
+ if (!full) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
306
+ const content = await readFile(full, 'utf8').catch((err) => {
307
+ if (err?.code === 'ENOENT') return null
308
+ throw err
309
+ })
310
+ if (content === null) return `错误:没有这个文件:${basename(full)}`
311
+ if (content.length > READ_LIMIT) {
312
+ return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符)`
313
+ }
314
+ return content
315
+ }
316
+ case 'audit_memory': {
317
+ if (!auditCache) auditCache = await auditMemoryDir(memoryDir)
318
+ return formatAudit(auditCache, 20)
319
+ }
320
+ case 'search_sessions': {
321
+ if (!withSessions) return '错误:本次 dream 的输入源不包含会话记录,search_sessions 不可用。'
322
+ const lim = Number.isFinite(a.limit) ? Math.max(1, Math.min(60, Number(a.limit))) : 20
323
+ return await searchSessions(sessionsRoot, a.query, lim, 0)
324
+ }
325
+ case 'write_memory': {
326
+ if (!apply) return '错误:当前是「只出报告」模式,没有写权限。请把改动写进你的最终回复。'
327
+ const full = safeMemoryPath(memoryDir, a.file)
328
+ if (!full) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
329
+ if (typeof a.content !== 'string' || !a.content.trim()) return '错误:content 为空。'
330
+ if (!/^---\r?\n/.test(a.content)) {
331
+ return '错误:内容必须以 `---` 开头的 YAML frontmatter 起始。参考 list_memory 里已有文件的写法。'
332
+ }
333
+ if (a.content.length > 512 * 1024) return `错误:内容过大(${a.content.length} 字符)。`
334
+ await writeFile(full, a.content, 'utf8')
335
+ touched.push(basename(full))
336
+ listCache = null
337
+ auditCache = null
338
+ return `已写入 ${basename(full)}(${a.content.length} 字符)。`
339
+ }
340
+ case 'archive_memory': {
341
+ if (!apply) return '错误:当前是「只出报告」模式,没有归档权限。请把建议写进你的最终回复。'
342
+ const full = safeMemoryPath(memoryDir, a.file)
343
+ if (!full) return `错误:文件名不合法:${String(a.file)}`
344
+ const exists = await stat(full).catch(() => null)
345
+ if (!exists) return `错误:没有这个文件:${basename(full)}`
346
+ const archiveDir = join(memoryDir, ARCHIVE_DIR)
347
+ await mkdir(archiveDir, { recursive: true })
348
+ const target = join(archiveDir, basename(full))
349
+ const clash = await stat(target).catch(() => null)
350
+ if (clash) return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
351
+ await rename(full, target)
352
+ touched.push(`${basename(full)} → archive/`)
353
+ listCache = null
354
+ auditCache = null
355
+ return `已归档 ${basename(full)}(原因:${String(a.reason || '未写')})。`
356
+ }
357
+ default:
358
+ return `错误:没有这个工具:${name}`
359
+ }
360
+ } catch (err) {
361
+ return `工具执行失败:${err?.message ?? String(err)}`
362
+ }
363
+ },
364
+ }
365
+ }