sage-mem 2.1.16 → 2.1.18
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/autodream/audit.js +63 -21
- package/lib/memory/index-file.js +31 -1
- package/lib/memory/scan.js +76 -11
- package/lib/tools/memory-tools.js +26 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.1.
|
|
15
|
+
**Current release: `2.1.18`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.1.
|
|
17
|
+
**当前发布版本:`2.1.18`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
package/lib/autodream/audit.js
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
* sage-mem autodream — 记忆目录审计(七查)。
|
|
3
3
|
*
|
|
4
4
|
* 审计清单内置在 host 侧,让 autodream 每次跑完都能自证
|
|
5
|
-
*
|
|
6
|
-
* 1. 索引悬空 ——
|
|
5
|
+
* 没把记忆改坏(七项,外加「读不动」「归档残留」「索引大小写冲突」各单列一类):
|
|
6
|
+
* 1. 索引悬空 —— 索引指向不存在的文件
|
|
7
7
|
* 2. 漏索引 —— 存在但没进索引的记忆文件
|
|
8
8
|
* 3. type 与前缀不一致(分类看 type,前缀骗不了它)
|
|
9
9
|
* 4. CRLF —— 「某个 Windows 编辑器碰过」的可疑信号
|
|
10
10
|
* 5. 双链断链 —— [[目标]] 找不到对应文件或 frontmatter name
|
|
11
11
|
* 6. 未加引号值里的「空格+#」—— 会被 YAML 当注释吞掉,解析不报错但描述残缺
|
|
12
|
+
* 7. 索引大小写冲突 —— 同一目录同时存在 MEMORY.md / memory.md 等多个变体
|
|
13
|
+
* (只在大小写敏感的卷上可能出现):读哪一个都不算错,**歧义本身**要报。
|
|
14
|
+
* 索引的识别一律走 `memory/index-file.js`,不在本模块写死文件名。
|
|
12
15
|
*
|
|
13
16
|
* **归档区(`archive/`)不是审计对象**,只贡献「名字池」:正文里指向已归档记忆的
|
|
14
17
|
* `[[链接]]` 单列成 `archivedLinks`(提示),**不计入 problems** —— 归档是「静默失效
|
|
@@ -24,14 +27,12 @@
|
|
|
24
27
|
import { readdir, readFile } from 'node:fs/promises'
|
|
25
28
|
import { join } from 'node:path'
|
|
26
29
|
import { hasFrontmatter, parseFrontmatter } from '../memory/frontmatter.js'
|
|
30
|
+
import { isIndexName, resolveIndexNames } from '../memory/index-file.js'
|
|
27
31
|
import { parseValidityTime } from '../memory/validity.js'
|
|
28
32
|
import { SESSION_LOG_KEY, isPlainMdName } from '../shared/md-name.js'
|
|
29
33
|
import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from '../memory/scan.js'
|
|
30
34
|
import { warnLog } from '../memory/util.js'
|
|
31
35
|
|
|
32
|
-
/** 索引文件名。它本身不是一条记忆,是索引。 */
|
|
33
|
-
const INDEX_FILE = 'MEMORY.md'
|
|
34
|
-
|
|
35
36
|
/**
|
|
36
37
|
* 归档 / 恢复中途崩溃留下的**私有名残留**:`a.md.archiving-<hex>` / `a.md.restoring-<hex>`。
|
|
37
38
|
*
|
|
@@ -55,7 +56,7 @@ const STRAY_RE = /\.(?:archiving|restoring)-[0-9a-f]+$/i
|
|
|
55
56
|
* ⚠️ 别和 `lib/memory/config.js` 的 `NON_ENTRY_FILES` 混为一谈 —— **同名不同义**:
|
|
56
57
|
* 那个管「列不列进设置页 / 星图 / 检索」,这个管「审计读不读」。它们的差集就是
|
|
57
58
|
* **索引 `MEMORY.md`**:它**要**被读(要当链接来源、要查行尾与链接),只是
|
|
58
|
-
* **不是一条记忆** —— 见下面 `
|
|
59
|
+
* **不是一条记忆** —— 见下面 `isIndexName` 那几处。
|
|
59
60
|
*/
|
|
60
61
|
const AUDIT_SKIP_FILES = new Set([SESSION_LOG_KEY])
|
|
61
62
|
|
|
@@ -123,8 +124,14 @@ export function indexTargets(indexText) {
|
|
|
123
124
|
/**
|
|
124
125
|
* 扫一个目录下的记忆文件(不含子目录 —— 归档区就该扫不到),
|
|
125
126
|
* 外加归档区的**名字池**(只有名字,不做检查)。
|
|
127
|
+
*
|
|
128
|
+
* 索引的识别与读取**不在这里写死文件名**:走 `memory/index-file.js` 的
|
|
129
|
+
* `resolveIndexNames`(大小写不敏感、多个变体时给出冲突列表),与「恢复归档时补索引」
|
|
130
|
+
* 等入口同一套规则。外部审查 Q17 的根因就是这里原先硬编码 `MEMORY.md`。
|
|
131
|
+
*
|
|
126
132
|
* @param {string} dir
|
|
127
|
-
* @returns {Promise<{files: string[], all: string[], skipped: number, indexText: string,
|
|
133
|
+
* @returns {Promise<{files: string[], all: string[], skipped: number, indexText: string,
|
|
134
|
+
* indexFile: string|null, indexVariants: string[], archiveKeys: Set<string>, archiveCount: number}>}
|
|
128
135
|
*/
|
|
129
136
|
async function readDirSnapshot(dir) {
|
|
130
137
|
let entries
|
|
@@ -132,6 +139,12 @@ async function readDirSnapshot(dir) {
|
|
|
132
139
|
const all = entries
|
|
133
140
|
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
134
141
|
.map((e) => e.name)
|
|
142
|
+
/**
|
|
143
|
+
* 索引的**实际磁盘名**:大小写不敏感地定位(与 `index-file.js` 同一套规则)。
|
|
144
|
+
* 同时拿到全部大小写变体 —— 多个变体只在大小写敏感的卷上可能同时存在,
|
|
145
|
+
* 那时目录本身有歧义,报出来而不是静默挑一个。
|
|
146
|
+
*/
|
|
147
|
+
const index = resolveIndexNames(entries.filter((e) => e.isFile()).map((e) => e.name))
|
|
135
148
|
const files = []
|
|
136
149
|
for (const f of all) {
|
|
137
150
|
if (AUDIT_SKIP_FILES.has(f.toLowerCase())) continue
|
|
@@ -150,10 +163,12 @@ async function readDirSnapshot(dir) {
|
|
|
150
163
|
files.push(f)
|
|
151
164
|
}
|
|
152
165
|
let indexText = ''
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
if (index.name !== null) {
|
|
167
|
+
try {
|
|
168
|
+
indexText = await readFile(join(dir, index.name), 'utf8')
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err?.code !== 'ENOENT') throw err
|
|
171
|
+
}
|
|
157
172
|
}
|
|
158
173
|
// 归档区:文件名、去掉 .md 的名字、以及 frontmatter 里的 `name`。
|
|
159
174
|
// 内容读不动只少一个名字,绝不让整场审计失败 —— 归档区本来就不是被审对象。
|
|
@@ -176,7 +191,17 @@ async function readDirSnapshot(dir) {
|
|
|
176
191
|
} catch (err) {
|
|
177
192
|
if (err?.code !== 'ENOENT') throw err
|
|
178
193
|
}
|
|
179
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
files,
|
|
196
|
+
all,
|
|
197
|
+
skipped: all.length - files.length,
|
|
198
|
+
indexText,
|
|
199
|
+
// 实际读取的索引名(null = 没有索引),以及全部大小写变体(>1 = 冲突)。
|
|
200
|
+
indexFile: index.name,
|
|
201
|
+
indexVariants: index.variants,
|
|
202
|
+
archiveKeys,
|
|
203
|
+
archiveCount,
|
|
204
|
+
}
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
/**
|
|
@@ -213,12 +238,21 @@ function stripCode(raw) {
|
|
|
213
238
|
* @param {{nowMs?: number}} [opts] — `nowMs` 只用于「未来 `invalid_at`」那条提示:
|
|
214
239
|
* 审计本身要读盘(不纯),但同一条规律仍然成立 —— 判定依赖的时钟必须能由调用方钉住,
|
|
215
240
|
* 否则夹具没法写出「未来」这种确定性场景(R3)。不传才用当下。
|
|
216
|
-
* @returns {Promise<object>} 结构化审计结果(problems
|
|
241
|
+
* @returns {Promise<object>} 结构化审计结果(problems 是所有计分项之和)
|
|
217
242
|
*/
|
|
218
243
|
export async function auditMemoryDir(dir, opts = {}) {
|
|
219
|
-
const { files, all, skipped, indexText, archiveKeys, archiveCount } =
|
|
244
|
+
const { files, all, skipped, indexText, indexFile, indexVariants, archiveKeys, archiveCount } =
|
|
245
|
+
await readDirSnapshot(dir)
|
|
220
246
|
const nowMs = Number.isFinite(opts?.nowMs) ? opts.nowMs : Date.now()
|
|
221
|
-
|
|
247
|
+
// 索引本身不是一条记忆:大小写归一后排除(`memory.md` / `MEMORY.MD` 一样排除)。
|
|
248
|
+
const entries = files.filter((f) => !isIndexName(f))
|
|
249
|
+
/**
|
|
250
|
+
* 索引的大小写变体同时存在 = 目录本身有歧义(只在大小写敏感卷上可能)。
|
|
251
|
+
*
|
|
252
|
+
* 定位规则会优先读规范名,但**另一份文件仍在磁盘上**:用户改了它不会生效。
|
|
253
|
+
* 这必须单独报出来让人删掉多余的,而不是静默挑一个(Q17)。
|
|
254
|
+
*/
|
|
255
|
+
const indexConflicts = indexVariants.length > 1 ? indexVariants.map((file) => ({ file })) : []
|
|
222
256
|
|
|
223
257
|
const targets = indexTargets(indexText)
|
|
224
258
|
const targetSet = new Set(targets.map((t) => t.toLowerCase()))
|
|
@@ -293,24 +327,25 @@ export async function auditMemoryDir(dir, opts = {}) {
|
|
|
293
327
|
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
294
328
|
if (!fmMatch) {
|
|
295
329
|
/**
|
|
296
|
-
*
|
|
297
|
-
* `NON_ENTRY_FILES`
|
|
330
|
+
* 索引(`MEMORY.md`,**大小写不敏感**:memory.md / MEMORY.MD 等同)是
|
|
331
|
+
* **纯清单、不是一条记忆**(见 `lib/memory/config.js` 的 `NON_ENTRY_FILES` 说明),
|
|
332
|
+
* 不要求 frontmatter。报它等于给每一个健康的记忆库挂一盏常驻红灯。
|
|
298
333
|
*/
|
|
299
|
-
if (f
|
|
334
|
+
if (!isIndexName(f)) noFrontmatter.push(f)
|
|
300
335
|
} else {
|
|
301
336
|
const meta = parseFrontmatterLite(raw)
|
|
302
337
|
const prefix = f.split('_')[0]
|
|
303
338
|
const effective = meta.type || 'reference'
|
|
304
339
|
/**
|
|
305
|
-
* ⚠️ **索引同样豁免「type 与前缀」这条**,与上面
|
|
340
|
+
* ⚠️ **索引同样豁免「type 与前缀」这条**,与上面 `!isIndexName(f)` 是同一个理由。
|
|
306
341
|
*
|
|
307
|
-
*
|
|
342
|
+
* 索引名没有下划线,`f.split('_')[0]` 得到的就是整个文件名;而历史版本的
|
|
308
343
|
* `write_memory` 硬要求内容以 frontmatter 开头,模型只好给索引补一个 `type: reference`
|
|
309
344
|
* —— 于是**每一个健康的记忆库都常驻一条**「type=reference 前缀=MEMORY.md」,把
|
|
310
345
|
* `problems` 这个唯一健康数字变成恒大于 0 的噪音(外部报告 2026-10-25 抓到)。
|
|
311
346
|
* 索引不是一条记忆,它没有「前缀」可言,这条检查对它不成立。
|
|
312
347
|
*/
|
|
313
|
-
if (f
|
|
348
|
+
if (!isIndexName(f) && effective !== prefix) {
|
|
314
349
|
typeMismatch.push({ file: f, type: meta.type || '(缺失)', prefix, effective })
|
|
315
350
|
}
|
|
316
351
|
for (const line of fmMatch[1].split(/\r?\n/)) {
|
|
@@ -383,6 +418,7 @@ export async function auditMemoryDir(dir, opts = {}) {
|
|
|
383
418
|
const problems =
|
|
384
419
|
dangling.length +
|
|
385
420
|
unlisted.length +
|
|
421
|
+
indexConflicts.length +
|
|
386
422
|
typeMismatch.length +
|
|
387
423
|
crlf.length +
|
|
388
424
|
brokenLinks.length +
|
|
@@ -397,8 +433,12 @@ export async function auditMemoryDir(dir, opts = {}) {
|
|
|
397
433
|
skipped,
|
|
398
434
|
archivedCount: archiveCount,
|
|
399
435
|
indexEntries: targets.length,
|
|
436
|
+
// 实际读取的索引名(null = 没有索引):大小写不敏感地定位,不用猜盘上是哪一种写法。
|
|
437
|
+
indexFile,
|
|
400
438
|
dangling: dangling.map((file) => ({ file })),
|
|
401
439
|
unlisted: unlisted.map((file) => ({ file })),
|
|
440
|
+
// 同一目录存在多个大小写变体(只在大小写敏感卷上可能)—— 单列一类、计入 problems。
|
|
441
|
+
indexConflicts,
|
|
402
442
|
typeMismatch,
|
|
403
443
|
crlf: crlf.map((file) => ({ file })),
|
|
404
444
|
brokenLinks,
|
|
@@ -432,6 +472,8 @@ export function formatAudit(report, limit = 12) {
|
|
|
432
472
|
}
|
|
433
473
|
section('索引悬空', report.dangling, (r) => r.file)
|
|
434
474
|
section('漏索引', report.unlisted, (r) => r.file)
|
|
475
|
+
// 多个大小写变体各自都可能是「用户以为的那份索引」:两边都列出来,让他删掉多余的。
|
|
476
|
+
section('索引大小写冲突(同一目录存在多个变体,请只保留一个)', report.indexConflicts ?? [], (r) => r.file)
|
|
435
477
|
section('type 与前缀不一致', report.typeMismatch, (r) => `${r.file} type=${r.type} 前缀=${r.prefix}`)
|
|
436
478
|
section('CRLF', report.crlf, (r) => r.file)
|
|
437
479
|
section('双链断链', report.brokenLinks, (r) => `${r.file} -> [[${r.target}]]`)
|
package/lib/memory/index-file.js
CHANGED
|
@@ -50,13 +50,43 @@ async function readIndexText(indexPath) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** 索引的规范文件名。大小写敏感卷上同时存在多个变体时,优先读它(与文档写法一致)。 */
|
|
54
|
+
export const CANONICAL_INDEX_NAME = 'MEMORY.md'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 这个名字算不算索引(大小写不敏感)——**唯一**判据,与 `findIndexFile` 同一套。
|
|
58
|
+
*
|
|
59
|
+
* 为什么单列出来:体检(`autodream/audit.js`)原先硬编码 `MEMORY.md`,排除索引时
|
|
60
|
+
* 又用精确字符串比较,于是索引合法地叫 `memory.md` 时,体检把索引本身报成
|
|
61
|
+
* 「漏索引 + 无 frontmatter」(外部审查 Q17)。所有「这是不是索引」的判断都走这里。
|
|
62
|
+
*/
|
|
63
|
+
export function isIndexName(name) {
|
|
64
|
+
return nameKey(name) === INDEX_KEY
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 从一组目录项名里定位索引:返回**全部大小写变体**与「该读哪一个」。
|
|
69
|
+
*
|
|
70
|
+
* 多个变体只可能在大小写敏感的卷上同时存在(NTFS 默认做不到同一目录两份)。
|
|
71
|
+
* 这里优先规范名 `MEMORY.md`,否则按字典序取第一个 —— 保证同一目录每次挑同一个;
|
|
72
|
+
* 同时把变体列表交出去,让调用方**明确报冲突**,而不是静默挑一个(Q17)。
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} names
|
|
75
|
+
* @returns {{name: string|null, variants: string[]}}
|
|
76
|
+
*/
|
|
77
|
+
export function resolveIndexNames(names) {
|
|
78
|
+
const variants = (Array.isArray(names) ? names : []).filter((n) => isIndexName(n)).sort()
|
|
79
|
+
if (variants.length === 0) return { name: null, variants }
|
|
80
|
+
return { name: variants.includes(CANONICAL_INDEX_NAME) ? CANONICAL_INDEX_NAME : variants[0], variants }
|
|
81
|
+
}
|
|
82
|
+
|
|
53
83
|
/**
|
|
54
84
|
* 索引文件的**磁盘真名**(大小写不敏感地找)。
|
|
55
85
|
* 不假设盘上是 `MEMORY.md` —— 系统盘是 Windows、用户可能就写成了小写。
|
|
56
86
|
*/
|
|
57
87
|
export async function findIndexFile(memoryDir) {
|
|
58
88
|
const names = await readdir(memoryDir).catch(() => [])
|
|
59
|
-
return names.
|
|
89
|
+
return resolveIndexNames(names).name
|
|
60
90
|
}
|
|
61
91
|
|
|
62
92
|
/** 索引里有没有指向该文件的条目(`](file.md)` 或 `](file.md#L12)`,大小写不敏感)。 */
|
package/lib/memory/scan.js
CHANGED
|
@@ -354,8 +354,26 @@ export async function scanMemoryFiles(ctx, sessionId) {
|
|
|
354
354
|
return scanMemoryDir(ctx, sessionId, MEMORY_DIR)
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
/**
|
|
358
|
+
* 同 `scanMemoryFiles`,但把「扫描到底完整不完整」一并带出来(外部审查 2026-10-31,Q14)。
|
|
359
|
+
*
|
|
360
|
+
* 返回 `{files, complete, errors, skipped, missing}`:
|
|
361
|
+
* - `complete === false` → **目录级**读取失败(ENOTDIR / EACCES / 盘掉线…):`files` 为空,
|
|
362
|
+
* 但上层**绝不能**当成「空目录、没有命中」;`errors` 是 `{code, message}` 列表。
|
|
363
|
+
* - `missing === true` → 目录不存在(全新安装还没建过记忆):合法空库,不是故障。
|
|
364
|
+
* - `skipped` → 目录列得出来、但单条目被读取闸门跳过的份数(链接 / 超限 / 保留名别名 /
|
|
365
|
+
* 读失败),与目录级失败分开统计。
|
|
366
|
+
*
|
|
367
|
+
* 旧签名 `scanMemoryFiles` / `scanMemoryDir` 仍返回数组(调用点太多,不一次性改爆)。
|
|
368
|
+
*/
|
|
369
|
+
export async function scanMemoryFilesDetailed(ctx, sessionId) {
|
|
370
|
+
return scanMemoryDirDetailed(ctx, sessionId, MEMORY_DIR)
|
|
371
|
+
}
|
|
372
|
+
|
|
357
373
|
/**
|
|
358
374
|
* 同 `scanMemoryFiles`,但目录可显式给(缓存按目录分账;测试用它证明两个目录不互相污染)。
|
|
375
|
+
* 返回值额外带扫描完整性(见 `scanMemoryFilesDetailed`);缓存只在**扫描完整**时落,
|
|
376
|
+
* 免得把一次失败存成「目录是空的」。
|
|
359
377
|
*
|
|
360
378
|
* 失效判据**两层**,缺一不可:
|
|
361
379
|
* 1. **主判据 —— watcher 代次**:目录里发生过事件就重扫。它也是「只改内容、不改名」这类
|
|
@@ -373,7 +391,7 @@ export async function scanMemoryFiles(ctx, sessionId) {
|
|
|
373
391
|
* watcher 不可用(起不来 / 报错)时**一律全扫**,与加缓存之前逐字一致 —— 拿不到失效信号
|
|
374
392
|
* 就不许缓存,这是这条优化的安全底线。
|
|
375
393
|
*/
|
|
376
|
-
export async function
|
|
394
|
+
export async function scanMemoryDirDetailed(ctx, sessionId, dir = MEMORY_DIR) {
|
|
377
395
|
const st = scanStateOf(dir)
|
|
378
396
|
// ── 命中检查:代次 + 文件名集合,两道都对上才算「目录真的没变」──
|
|
379
397
|
if (st.watcher.ok && st.entry) {
|
|
@@ -386,7 +404,15 @@ export async function scanMemoryDir(ctx, sessionId, dir = MEMORY_DIR) {
|
|
|
386
404
|
*/
|
|
387
405
|
if (names && namesKeyOf(names) === st.entry.namesKey && st.generation === g0) {
|
|
388
406
|
// 还一个新数组:缓存是共享的,调用方就地排序 / splice 不该改到别人的那一份。
|
|
389
|
-
return
|
|
407
|
+
return {
|
|
408
|
+
files: st.entry.files.slice(),
|
|
409
|
+
complete: true,
|
|
410
|
+
missing: false,
|
|
411
|
+
errors: [],
|
|
412
|
+
skipped: st.entry.skipped ?? 0,
|
|
413
|
+
namesKey: st.entry.namesKey,
|
|
414
|
+
cached: true,
|
|
415
|
+
}
|
|
390
416
|
}
|
|
391
417
|
}
|
|
392
418
|
}
|
|
@@ -405,32 +431,59 @@ export async function scanMemoryDir(ctx, sessionId, dir = MEMORY_DIR) {
|
|
|
405
431
|
st.watcher.dispose()
|
|
406
432
|
st.watcher = null
|
|
407
433
|
}
|
|
408
|
-
if (st.watcher && st.watcher.ok && scanned.namesKey !== null && gStart === gEnd) {
|
|
409
|
-
st.entry = { generation: gEnd, namesKey: scanned.namesKey, files: scanned.files }
|
|
434
|
+
if (st.watcher && st.watcher.ok && scanned.complete !== false && scanned.namesKey !== null && gStart === gEnd) {
|
|
435
|
+
st.entry = { generation: gEnd, namesKey: scanned.namesKey, files: scanned.files, skipped: scanned.skipped }
|
|
410
436
|
}
|
|
411
437
|
// 也还新数组:上面刚把同一份引用存进了缓存,交出去就等于把缓存的所有权给了调用方。
|
|
412
|
-
return scanned.files.slice()
|
|
438
|
+
return { ...scanned, files: scanned.files.slice() }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 兼容旧签名:只要文件数组(要分辨「空库 vs 读不了」用 `scanMemoryDirDetailed`)。 */
|
|
442
|
+
export async function scanMemoryDir(ctx, sessionId, dir = MEMORY_DIR) {
|
|
443
|
+
return (await scanMemoryDirDetailed(ctx, sessionId, dir)).files
|
|
413
444
|
}
|
|
414
445
|
|
|
415
446
|
/**
|
|
416
|
-
* 真的走一遍磁盘。返回 `{files, namesKey}`。
|
|
447
|
+
* 真的走一遍磁盘。返回 `{files, namesKey, complete, missing, errors, skipped}`。
|
|
417
448
|
*
|
|
418
|
-
* `namesKey === null`
|
|
449
|
+
* `namesKey === null` 表示目录根本没列出来 → 这一轮**不落缓存**:
|
|
419
450
|
* 把一次失败当成「目录是空的」存下来,会让之后每一次读取都命中这份假结果。
|
|
451
|
+
* `complete === false` 是**目录级**失败(ENOTDIR / EACCES…);`missing === true` 是目录不存在,
|
|
452
|
+
* 属于合法空库 —— 两者刻意分开,上层据此决定「说空库」还是「说检索不可用」。
|
|
420
453
|
*/
|
|
421
454
|
async function fullScanMemoryDir(ctx, sessionId, dir) {
|
|
422
455
|
try {
|
|
423
|
-
|
|
456
|
+
let entries
|
|
457
|
+
try {
|
|
458
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
459
|
+
} catch (err) {
|
|
460
|
+
// 目录级 I/O 失败(ENOTDIR / EACCES / 盘掉线…)与「目录还没建过」必须分开:
|
|
461
|
+
// 前者要带 complete=false 让上层说「检索不可用」,后者是合法空库。
|
|
462
|
+
const code = String(err?.code ?? '')
|
|
463
|
+
if (code === 'ENOENT') return { files: [], namesKey: null, complete: true, missing: true, errors: [], skipped: 0 }
|
|
464
|
+
warnLog(ctx, `cannot scan ${dir}: ${err?.message ?? err}`, sessionId)
|
|
465
|
+
return {
|
|
466
|
+
files: [],
|
|
467
|
+
namesKey: null,
|
|
468
|
+
complete: false,
|
|
469
|
+
missing: false,
|
|
470
|
+
errors: [{ code, message: String(err?.message ?? err) }],
|
|
471
|
+
skipped: 0,
|
|
472
|
+
}
|
|
473
|
+
}
|
|
424
474
|
// 目录名集合的指纹:作为缓存的**兜底判据**(watcher 漏掉增 / 删 / 改名时的第二道闸)。
|
|
425
475
|
const namesKey = namesKeyOf(entries.map(e => e.name))
|
|
426
476
|
/** 保留名的文件身份:8.3 短名与硬链接都得靠它才认得出(名字文本上它们毫无关系)。 */
|
|
427
477
|
const reservedIds = await reservedIdentitySet(dir)
|
|
478
|
+
/** 目录列得出来、但单个条目没读成 / 被策略跳过的份数(与目录级失败分开统计)。 */
|
|
479
|
+
let skipped = 0
|
|
428
480
|
/**
|
|
429
481
|
* 链接条目在 `isFile()` 这一步就被滤掉了(`readdir` 的 Dirent 报的是链接本身),
|
|
430
482
|
* 也就是说**闸门根本没机会看到它们** —— 不在这里出声,用户只会看到「这条记忆不见了」。
|
|
431
483
|
*/
|
|
432
484
|
for (const e of entries) {
|
|
433
485
|
if (e.isSymbolicLink() && /\.md$/i.test(e.name)) {
|
|
486
|
+
skipped += 1
|
|
434
487
|
warnLog(ctx, `skip ${e.name}: 是指向别处的符号链接 / 目录联接`, sessionId)
|
|
435
488
|
}
|
|
436
489
|
}
|
|
@@ -447,6 +500,7 @@ async function fullScanMemoryDir(ctx, sessionId, dir) {
|
|
|
447
500
|
* 文本上毫无关系,inode 却是同一个。不按身份拦,索引就会以普通记忆的身份被读进上下文。
|
|
448
501
|
*/
|
|
449
502
|
if (gate.ok && gate.id && reservedIds.has(gate.id)) {
|
|
503
|
+
skipped += 1
|
|
450
504
|
warnLog(ctx, `skip ${name}: 这是某个保留名(索引 / 流水)的另一个名字,不当记忆读`, sessionId)
|
|
451
505
|
return null
|
|
452
506
|
}
|
|
@@ -455,6 +509,7 @@ async function fullScanMemoryDir(ctx, sessionId, dir) {
|
|
|
455
509
|
warnLog(ctx, `note ${name}: 有 ${gate.links} 个硬链接名,内容可能被记忆目录之外改到`, sessionId)
|
|
456
510
|
}
|
|
457
511
|
if (!gate.ok) {
|
|
512
|
+
skipped += 1
|
|
458
513
|
warnLog(ctx, `skip ${name}: ${gate.error}`, sessionId)
|
|
459
514
|
return null
|
|
460
515
|
}
|
|
@@ -487,14 +542,24 @@ async function fullScanMemoryDir(ctx, sessionId, dir) {
|
|
|
487
542
|
} catch (err) {
|
|
488
543
|
// 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
|
|
489
544
|
// 一律出声:这里的静默化就是把「记忆不再注入」变成无声事故的那一手。
|
|
545
|
+
skipped += 1
|
|
490
546
|
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`, sessionId)
|
|
491
547
|
return null
|
|
492
548
|
}
|
|
493
549
|
}))
|
|
494
|
-
return { files: loaded.filter(Boolean), namesKey }
|
|
550
|
+
return { files: loaded.filter(Boolean), namesKey, complete: true, missing: false, errors: [], skipped }
|
|
495
551
|
} catch (err) {
|
|
496
|
-
|
|
497
|
-
|
|
552
|
+
// 兜底:扫描中途出现未预期异常时同样**不能**当空库 —— 带 complete=false 交给上层。
|
|
553
|
+
const code = String(err?.code ?? '')
|
|
554
|
+
warnLog(ctx, `cannot scan ${dir}: ${err?.message ?? err}`, sessionId)
|
|
555
|
+
return {
|
|
556
|
+
files: [],
|
|
557
|
+
namesKey: null,
|
|
558
|
+
complete: false,
|
|
559
|
+
missing: false,
|
|
560
|
+
errors: [{ code, message: String(err?.message ?? err) }],
|
|
561
|
+
skipped: 0,
|
|
562
|
+
}
|
|
498
563
|
}
|
|
499
564
|
}
|
|
500
565
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { DEFAULT_MAX_BYTES, MAX_MAX_BYTES, readSlice } from '../memory/reader.js'
|
|
20
20
|
import { DEFAULT_LIMIT, MAX_LIMIT, retrieve } from '../memory/search.js'
|
|
21
|
-
import {
|
|
21
|
+
import { scanMemoryFilesDetailed } from '../memory/scan.js'
|
|
22
22
|
import { parseFrontmatter } from '../memory/frontmatter.js'
|
|
23
23
|
import { describeValidity } from '../memory/validity.js'
|
|
24
24
|
|
|
@@ -37,6 +37,10 @@ function renderSearchResult(args, value) {
|
|
|
37
37
|
const cov = v.coverage ?? {}
|
|
38
38
|
const head = [
|
|
39
39
|
`检索面:${v.scope ?? 'all'};扫了 ${cov.searched ?? 0} 份记忆(切出 ${cov.chunks ?? 0} 个段落)`,
|
|
40
|
+
// 目录不存在 = 还没建过记忆(合法空库),说出来,别让「空」被读成「扫过且没有」。
|
|
41
|
+
cov.missingDir ? ';记忆目录还不存在(可能还没建过任何记忆)' : '',
|
|
42
|
+
// 单条目被闸门跳过(链接 / 超限 / 保留名别名 / 读失败):与「目录级读不了」分开报。
|
|
43
|
+
cov.skippedFiles ? `;有 ${cov.skippedFiles} 份被读取闸门跳过(链接 / 超限 / 读不了)` : '',
|
|
40
44
|
cov.skipped ? `;有 ${cov.skipped} 份没读成` : '',
|
|
41
45
|
cov.filteredBelowFloor ? `;另有 ${cov.filteredBelowFloor} 段因相关性不够被滤掉` : '',
|
|
42
46
|
`。`,
|
|
@@ -118,7 +122,22 @@ export function createMemoryTools(ctx) {
|
|
|
118
122
|
const query = String(args?.query ?? '').trim()
|
|
119
123
|
if (!query) return { ok: false, error: 'query 不能为空' }
|
|
120
124
|
try {
|
|
121
|
-
|
|
125
|
+
/**
|
|
126
|
+
* 用 Detailed 入口(外部审查 2026-10-31,Q14):目录级失败(ENOTDIR / EACCES…)
|
|
127
|
+
* 以前被 `scanMemoryDir` 降级成空数组,工具照样回 `ok:true` + `complete:true`,
|
|
128
|
+
* 「读不了记忆」就被渲染成「记忆里没有」。现在把「检索不可用」单独带出去。
|
|
129
|
+
*/
|
|
130
|
+
const scan = await scanMemoryFilesDetailed(ctx, exec?.agent?.id ?? '')
|
|
131
|
+
if (scan.complete === false) {
|
|
132
|
+
const detail = (scan.errors ?? []).map((e) => `${e.code ? `${e.code}: ` : ''}${e.message}`).join(';') || '目录读取失败'
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
code: 'memory_unavailable',
|
|
136
|
+
error: `记忆目录读不了(${detail})。这是「检索不可用」,不代表记忆里没有匹配项 —— 换个时间重试,或检查记忆目录是否存在、是否是个目录。`,
|
|
137
|
+
coverage: { searched: 0, complete: false, errors: scan.errors ?? [] },
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const files = scan.files
|
|
122
141
|
const result = retrieve({ query, files, scope: args?.scope, limit: args?.limit, cursor: args?.cursor })
|
|
123
142
|
// 2.0.0:把每条命中对应的失效信息带出去(D29 Q3)。检索本身**不过滤**失效记忆 ——
|
|
124
143
|
// 问历史时旧口径是证据;过滤掉才是丢信息。模型靠这里的标记判断「这是不是现状」。
|
|
@@ -135,6 +154,11 @@ export function createMemoryTools(ctx) {
|
|
|
135
154
|
if (v.expired) extra.expired = true
|
|
136
155
|
return Object.keys(extra).length ? { ...h, ...extra } : h
|
|
137
156
|
})
|
|
157
|
+
// 扫描层的「单条目被跳过」与目录级失败分开报(Q14):坏一份 ≠ 整库读不了。
|
|
158
|
+
if (result.coverage && typeof result.coverage === 'object') {
|
|
159
|
+
result.coverage.skippedFiles = scan.skipped ?? 0
|
|
160
|
+
if (scan.missing) result.coverage.missingDir = true
|
|
161
|
+
}
|
|
138
162
|
}
|
|
139
163
|
return result
|
|
140
164
|
} catch (err) {
|
package/package.json
CHANGED