sage-mem 2.1.16 → 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 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.16`** — 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
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.16`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
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 是最直接的反馈。
@@ -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 scanMemoryDir(ctx, sessionId, dir = MEMORY_DIR) {
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 st.entry.files.slice()
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
- const entries = await readdir(dir, { withFileTypes: true })
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
- if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${dir}: ${err?.message ?? err}`, sessionId)
497
- return { files: [], namesKey: null }
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 { scanMemoryFiles } from '../memory/scan.js'
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
- const files = await scanMemoryFiles(ctx, exec?.agent?.id ?? '')
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "2.1.16",
4
+ "version": "2.1.17",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",