sage-mem 2.1.15 → 2.1.17
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/memory/retrieval.js +34 -9
- package/lib/memory/scan.js +76 -11
- package/lib/memory/search.js +9 -4
- 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.17`** — 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.17`** —— 顶部 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/memory/retrieval.js
CHANGED
|
@@ -71,32 +71,43 @@ export const MIN_SUMMARY_EVIDENCE_RATIO = 0.6
|
|
|
71
71
|
*/
|
|
72
72
|
export const MIN_GATE_CORPUS = 8
|
|
73
73
|
|
|
74
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* 摘要面的检索文本:`description` + `aliases` + 文件名。**打分与语料统计必须用同一份**。
|
|
76
|
+
*
|
|
77
|
+
* 相关性打分**只看 frontmatter 的 description + 文件名,不扫全文**。旧版拿 query 的
|
|
78
|
+
* 双字去全文里找,得分是「覆盖率」,分母是 query、不惩罚文件长度 —— 越长的记忆越容易
|
|
79
|
+
* 蒙中。实测:长记忆在全文匹配下对任何问题都能蒙中足量双字组,连寒暄问句都能注入上千
|
|
80
|
+
* token;而真正该命中的那条反被挤掉。description 是「一句话说清这条是什么」的精准摘要,
|
|
81
|
+
* 用它当检索信号,噪音大幅下降。
|
|
82
|
+
*/
|
|
75
83
|
export function summaryHay(f) {
|
|
76
84
|
const aliasText = Array.isArray(f.aliases) && f.aliases.length ? ' ' + f.aliases.join(' ') : ''
|
|
77
85
|
return f.description + aliasText + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
/**
|
|
81
|
-
*
|
|
89
|
+
* 摘要候选的**完整排序**(不施加注入名额)。
|
|
90
|
+
*
|
|
91
|
+
* 与 `selectRelevant` 的分工(2.1.16 / 外部审查 Q13):
|
|
92
|
+
* - 自动注入只要前 `MAX_RESULTS` 条 → 用 `selectRelevant`,名额在**消费端**切;
|
|
93
|
+
* - 主动检索(`search_memory` / `retrieve`)要按自己的 limit/cursor 翻页 →
|
|
94
|
+
* 用这个函数拿**完整候选集**,否则下一页无从取起。
|
|
82
95
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* 蒙中足量双字组,连寒暄问句都能注入上千 token;而真正该命中的那条反被
|
|
86
|
-
* 挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
|
|
87
|
-
* 信号,噪音大幅下降。
|
|
96
|
+
* 旧版把 `MAX_RESULTS` 焊死在这条排序里:`SAGE_MEM_MAX_RESULTS=3` 时,主动检索
|
|
97
|
+
* `scope=summary, limit=10` 只看到 3 条、`nextCursor=null`,丢掉的候选翻页也取不回。
|
|
88
98
|
*
|
|
89
99
|
* @param {string} query
|
|
90
100
|
* @param {Array} files
|
|
91
101
|
* @param {{gate?: boolean, minRatio?: number}} [opts] — `gate:false` 关掉证据门槛
|
|
92
102
|
* (测试「名额上限」这类与相关性无关的用例要它)
|
|
103
|
+
* @returns {Array} 按分数降序的**全部**命中(每项是原文件对象 + `score`)
|
|
93
104
|
*/
|
|
94
|
-
export function
|
|
105
|
+
export function rankSummaries(query, files, opts = {}) {
|
|
95
106
|
const list = Array.isArray(files) ? files : []
|
|
96
107
|
const hay = list.map(summaryHay)
|
|
97
108
|
const entries = list.map((f, i) => ({ f, i, score: score(query, hay[i]) }))
|
|
98
109
|
const hit = entries.filter(e => e.score > 0).sort((a, b) => b.score - a.score)
|
|
99
|
-
const keep = (arr) => arr.
|
|
110
|
+
const keep = (arr) => arr.map(e => ({ ...e.f, score: e.score }))
|
|
100
111
|
if (opts.gate === false || hit.length === 0) return keep(hit)
|
|
101
112
|
// 语料太小就别用 IDF 判(见 MIN_GATE_CORPUS)。
|
|
102
113
|
if (list.length < MIN_GATE_CORPUS) return keep(hit)
|
|
@@ -123,6 +134,20 @@ export function selectRelevant(query, files, opts = {}) {
|
|
|
123
134
|
return keep(hit.filter(e => evidence(terms, tf[e.i], stats).sum >= minRatio * maxIdf))
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
/**
|
|
138
|
+
* 自动注入用的摘要选择:完整排序之后**只取注入名额内的条数**。
|
|
139
|
+
*
|
|
140
|
+
* 名额(`MAX_RESULTS`)仍然守自己的配置 —— 2.1.16 只把「排序」与「名额」拆开,
|
|
141
|
+
* 没有放开注入上限(那是 Q13 的反向对照)。
|
|
142
|
+
*
|
|
143
|
+
* @param {string} query
|
|
144
|
+
* @param {Array} files
|
|
145
|
+
* @param {{gate?: boolean, minRatio?: number}} [opts]
|
|
146
|
+
*/
|
|
147
|
+
export function selectRelevant(query, files, opts = {}) {
|
|
148
|
+
return rankSummaries(query, files, opts).slice(0, MAX_RESULTS)
|
|
149
|
+
}
|
|
150
|
+
|
|
126
151
|
/**
|
|
127
152
|
* 选 baseline 条目:按显式 `baselinePriority` 排,不按文件名。
|
|
128
153
|
*
|
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
|
|
package/lib/memory/search.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { chunkFile, revisionOf } from './chunks.js'
|
|
18
18
|
import { buildStats, coverageOf, evidence, maxMatchedRun, queryTermsOf, scoreDoc, termFreq, tokenize, COVERAGE_WEIGHT } from './bm25.js'
|
|
19
|
-
import { score as summaryScore
|
|
19
|
+
import { rankSummaries, score as summaryScore } from './retrieval.js'
|
|
20
20
|
import { extractTitle } from './scan.js'
|
|
21
21
|
|
|
22
22
|
/** 工具响应的默认条数与上限(稿子 §5.2 的起始值)。 */
|
|
@@ -236,14 +236,18 @@ export function rankChunksInternal(query, files, opts = {}) {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
/**
|
|
239
|
-
* 摘要命中(复用 retrieval.js
|
|
239
|
+
* 摘要命中(复用 retrieval.js 的摘要排序,不另写一套打分)。
|
|
240
|
+
*
|
|
241
|
+
* ⚠️ 用 `rankSummaries`(**不带注入名额**)而不是 `selectRelevant`:后者返回前
|
|
242
|
+
* `MAX_RESULTS` 条,而这里是主动检索的一页 —— 名额若先切一刀,本函数的 limit/cursor
|
|
243
|
+
* 就作用在**已被截断的集合**上,丢掉的候选翻页也取不回(外部审查 Q13)。
|
|
240
244
|
*
|
|
241
245
|
* 只额外做一件事:**说清是哪一栏命中的**。摘要命中与正文命中在展示层的处理不同
|
|
242
246
|
* (前者给指针就够,后者才值得端正文),所以这个理由是行为契约的一部分。
|
|
243
247
|
*/
|
|
244
248
|
export function summaryHits(query, files, limit = DEFAULT_LIMIT) {
|
|
245
249
|
if (!String(query ?? '').trim()) return []
|
|
246
|
-
return
|
|
250
|
+
return rankSummaries(query, files).slice(0, limit).map(f => {
|
|
247
251
|
const fileBase = f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
248
252
|
const matchedBy = []
|
|
249
253
|
if (f.description && summaryScore(query, f.description) > 0) matchedBy.push('description')
|
|
@@ -296,7 +300,8 @@ export function retrieve(input = {}) {
|
|
|
296
300
|
const byFile = new Map()
|
|
297
301
|
for (const hit of body) if (!byFile.has(hit.file)) byFile.set(hit.file, hit)
|
|
298
302
|
|
|
299
|
-
// `Infinity` =
|
|
303
|
+
// `Infinity` = 取满摘要排序的**全部**候选(2.1.16 / Q13:注入名额不再焊在这条排序里,
|
|
304
|
+
// 所以这里拿到的是完整合格集,本函数的 limit/cursor 才真正能翻页)。
|
|
300
305
|
// 曾经这里写的是 `limit + cursor` —— 池宽恰好等于本页窗口,于是 total 恒等于 cursor+limit、
|
|
301
306
|
// `nextCursor` 恒为 null,翻页**根本翻不动**(只有第一页)。池宽必须大于窗口。
|
|
302
307
|
const summary = scope === 'body' ? [] : summaryHits(query, files, Infinity)
|
|
@@ -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