sage-mem 0.9.20 → 1.0.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.
@@ -462,6 +462,71 @@ export function createToolRunner(opts) {
462
462
  * @param {'create'|'update'|'archive'} op
463
463
  * @param {'model'|'auto'} reasonSource — model = 模型自述;auto = 宿主追溯(模型没给)
464
464
  */
465
+ /**
466
+ * 「这份文件我们读到时是什么版本」——**按规范化路径**记,写的时候拿它当守卫。
467
+ *
468
+ * 为什么必须有这张表(外部复审 R01,P1 数据丢失):守卫如果取**写的那一刻**的版本,
469
+ * 它只保护 `stat → writeText` 这一小段,而真实的危险窗口是 **`读 → 生成 → 写`** 整个周期。
470
+ * 复现:读 A → 人工改成 B → 自动整理基于 A 生成 C → 写时 stat 拿到 B → 守卫通过 → **B 被覆盖**。
471
+ * 单进程里顺序交错就能发生,不需要多进程竞争。
472
+ *
473
+ * 存的是**读到内容那一刻的版本**:C 是基于 A 生成的,就该拿 A 去比。
474
+ * 磁盘已经是 B 时,守卫会以 `FS_STALE_VERSION` 拒绝,内容不丢。
475
+ */
476
+ const readVersions = new Map()
477
+
478
+ /**
479
+ * 记下一次读取对应的版本。取不到可信版本就**什么都不记**(宁可让写入走「拒绝」,
480
+ * 也不要拿一个来路不明的版本假装有守卫)。
481
+ *
482
+ * 一致性:宿主 fs 可用时用 `stat → 读 → stat`,**两次版本相同**才认这份内容是那个版本的快照。
483
+ * 中间被人改过就不记 —— 分开读拿不到原子快照,那就诚实地说「不知道」。
484
+ */
485
+ async function rememberReadVersion(fullPath, content, truncated) {
486
+ try {
487
+ const fs = hostFs()
488
+ if (!fs || typeof fs.stat !== 'function') return
489
+ const target = await fs.resolve(fullPath)
490
+ const before = await fs.stat(target)
491
+ if (!before?.version) return
492
+ const after = await fs.stat(target)
493
+ if (!after?.version || after.version !== before.version) return
494
+ const where = memoryPathOf(fullPath, memoryDir)
495
+ if (where.ok) readVersions.set(where.canon, { version: before.version, truncated: truncated === true })
496
+ } catch { /* 记不下就不记:调用方会按「未知版本」处理 */ }
497
+ }
498
+
499
+ /**
500
+ * 取「这次写入该用哪个守卫」。
501
+ *
502
+ * 三种情形,**每一种都是有意的**:
503
+ * - 文件不存在 → `createIfAbsent`(新建不可能覆盖谁)
504
+ * - 文件存在 + 我们记过它被读到时是哪个版本 → `replaceIfVersion(那个版本)`
505
+ * - 文件存在 + **没记过**(没读过 / 版本取不到 / 读的是截断内容)→ **不给守卫,由调用方拒绝**
506
+ *
507
+ * 第三种是 R01 的要害:以前这里退化成「用当前版本」,等于**替调用方担保了一份它没读过的内容**。
508
+ * 现在返回 `{refuse, why}`,让调用方把这次写入转成一条待人工处理的冲突,而不是盲写。
509
+ */
510
+ async function guardForWrite(fullPath) {
511
+ const fs = hostFs()
512
+ if (!fs || typeof fs.stat !== 'function') return { expected: undefined, known: false }
513
+ const target = await fs.resolve(fullPath)
514
+ let info
515
+ try {
516
+ info = await fs.stat(target)
517
+ } catch (err) {
518
+ // stat 抛错 = **不知道盘上是什么**。不知道就不写,而不是「不传守卫让宿主随便写」。
519
+ return { expected: undefined, known: false, refuse: true, why: `取不到当前版本(${err?.code ?? err?.message ?? err}),不能替你覆盖` }
520
+ }
521
+ // 「明确不存在」这一支仍走写门的判定表(`write-gate.js`)—— 能共用的就别写第二份。
522
+ if (!info) return { expected: decideWriteIntent({ memoryPath: memoryPathOf(fullPath, memoryDir), exists: false, version: null, caller: undefined }).intent, known: true }
523
+ const where = memoryPathOf(fullPath, memoryDir)
524
+ const seen = where.ok ? readVersions.get(where.canon) : undefined
525
+ if (!seen?.version) return { expected: undefined, known: false, refuse: true, why: '这份记忆我们没有读过(或版本取不到),不能替你覆盖它' }
526
+ if (seen.truncated) return { expected: undefined, known: false, refuse: true, why: '上次读到的是截断内容,不能拿它作为整份覆盖的依据' }
527
+ return { expected: { kind: 'replaceIfVersion', version: seen.version }, known: true }
528
+ }
529
+
465
530
  /**
466
531
  * 取宿主的 `fs` 服务,拿不到就返回 null。
467
532
  *
@@ -488,7 +553,7 @@ export function createToolRunner(opts) {
488
553
  * ⚠️ 这里**吞掉一切异常**:它发生在「写入已经失败」的路径上,再抛一次只会把真正的原因
489
554
  * (`FS_STALE_VERSION`)盖掉。留不下记录就只留日志,绝不改变向外抛的那个错误。
490
555
  */
491
- async function recordStaleConflict(fullPath, incoming, err) {
556
+ async function recordStaleConflict(fullPath, incoming, err, kind = 'stale_version') {
492
557
  try {
493
558
  const fs = hostFs()
494
559
  let onDisk = null
@@ -497,7 +562,7 @@ export function createToolRunner(opts) {
497
562
  if (fs && typeof fs.readText === 'function') onDisk = await fs.readText(await fs.resolve(fullPath))
498
563
  } catch { /* 读不到就只留我们这一版:宁缺一半,也不要为它把冲突记录整条丢掉 */ }
499
564
  const record = buildConflict({
500
- kind: 'stale_version',
565
+ kind,
501
566
  file: basename(fullPath),
502
567
  expectedVersion: 'unknown', // 守卫是我们自己按 stat 到的版本构造的,这里拿不到那个字符串
503
568
  actualVersion: String(err?.code ?? ''),
@@ -537,27 +602,21 @@ export function createToolRunner(opts) {
537
602
  }
538
603
  const target = await fs.resolve(fullPath)
539
604
  // ⚠️ **守卫必须由我们自己传。** `fs/write-intent` 只为「工具发起」的写入派发,
540
- // 插件自己调 `writeText` 时一次都不发(实测)—— 不自己传,这次写入就是**无守卫**的,
541
- // 那正是 D4 要避免的漏洞,也是「两套写入者静默互踩」的入口。
542
- // 判定表与写门用的是同一个(`write-gate.js`),所以两条路的语义不会漂。
543
- let expected
544
- if (typeof fs.stat === 'function') {
545
- try {
546
- const info = await fs.stat(target)
547
- expected = decideWriteIntent({
548
- memoryPath: memoryPathOf(fullPath, memoryDir),
549
- exists: !!info,
550
- version: info?.version ?? null,
551
- caller: undefined,
552
- }).intent
553
- } catch {
554
- // 探不到就不猜:不传守卫(宿主按默认语义走),也绝不退化成 createIfAbsent ——
555
- // 那对**其实存在**的文件是一次误拦。
556
- expected = undefined
557
- }
605
+ // 插件自己调 `writeText` 时一次都不发(实测)—— 不自己传,这次写入就是**无守卫**的。
606
+ //
607
+ // ⚠️⚠️ **而且必须是「读到内容那一刻」的版本,不是「现在」的版本。** 拿现在这个版本当守卫
608
+ // 只保护 `stat → writeText` 这一小段;真正的危险窗口是 `读 → 生成 → 写`。
609
+ // 外部复审 R01 就是这样复现出数据丢失的(读 A → 人工改 B → 基于 A 生成 C → 写时拿到 B 的版本
610
+ // → 守卫通过 → B 没了)。守卫的版本来自 `readVersions`,见 guardForWrite。
611
+ const guard = await guardForWrite(fullPath)
612
+ if (guard.refuse) {
613
+ // **不知道就不写。** 以前这条路会退化成「用当前版本当守卫」,等于替调用方担保一份它没读过的
614
+ // 内容 —— 那比「什么都不做」危险得多。转成一条待人工处理的冲突,让人来判。
615
+ await recordStaleConflict(fullPath, content, { code: 'UNVERIFIED_OVERWRITE', message: guard.why }, 'external_change')
616
+ throw Object.assign(new Error(guard.why), { code: 'SAGE_MEM_UNVERIFIED_OVERWRITE' })
558
617
  }
559
618
  try {
560
- return await fs.writeText(target, content, expected)
619
+ return await fs.writeText(target, content, guard.expected)
561
620
  } catch (err) {
562
621
  // 版本过期不是「写失败」,是**一次待判定的冲突**:有人在我们读入之后改过这份记忆。
563
622
  // 落一条记录(保留哪边由人决定),然后**照旧把错误抛出去** ——
@@ -643,8 +702,12 @@ export function createToolRunner(opts) {
643
702
  throw err
644
703
  })
645
704
  if (content === null) return `错误:没有这个文件:${basename(full)}`
646
- if (content.length > READ_LIMIT) {
647
- return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符)`
705
+ // 记下「这份内容对应磁盘上的哪个版本」——写回时用它当守卫(外部复审 R01)。
706
+ // 截断的读也要标出来:截断内容不足以作为整份覆盖的依据。
707
+ const truncated = content.length > READ_LIMIT
708
+ await rememberReadVersion(full, content, truncated)
709
+ if (truncated) {
710
+ return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符。注意:截断的读不能作为整份覆盖的依据)`
648
711
  }
649
712
  return content
650
713
  }
package/lib/index.js CHANGED
@@ -75,6 +75,59 @@ function dedupeByFile(hits) {
75
75
  return out
76
76
  }
77
77
 
78
+ /**
79
+ * 会话级注入状态:**一处持有、一处提交**。
80
+ *
81
+ * 为什么必须集中:原先这是三个散落的容器(检索签名 `recallCache`、baseline 标记
82
+ * `baselineInjected`、注入账本 `injectLedger`),而代次变化本该让它们**同时**归零 ——
83
+ * 旧代码把重置写在去重**之后**,于是同一问题在压缩后仍被上一代的检索签名挡住,
84
+ * 记忆再也回不来(外部复审 R03)。集中之后,顺序是能被读出来的:
85
+ *
86
+ * begin() 处理代次变化 → 调用方算正文与控制提示 → commit() 一次写回
87
+ *
88
+ * 为什么提交放最后:中途抛错时状态不该前进 —— 没注进去的东西不能记成「已注过」,
89
+ * 否则一次失败会把这条记忆永久挡在门外。唯一的例外是代次变化:那是**已经发生的事实**
90
+ * (上下文真的被压缩了),必须在算任何东西之前生效,否则这一步会拿着上一代的账做判断。
91
+ *
92
+ * ⚠️ 诚实边界:压缩信号是「消息条数下降」这条启发式,它**覆盖不了条数不降的压缩**
93
+ * (宿主原地改写历史,或截断后又在同一步补足条数)。那种情况下这里看不出代次变化,
94
+ * 记忆仍会被去重挡住。要真正解决得等宿主给出可比的上下文代次标识(目前没有)。
95
+ */
96
+ function createSessionInjectState(maxSessions = 64) {
97
+ const sessions = new Map()
98
+ const fresh = () => ({
99
+ signature: null, // 已注入过的检索签名(问题 + 命中文件集)
100
+ contentBytes: 0, // 本代正文累计字节
101
+ cardBytes: 0, // 本代状态卡累计字节(与正文分账,见 config.js)
102
+ cardSig: '', // 本代已发过的那张卡(含检查点句柄的合成文本)
103
+ gapCard: '', // 本代渲染过的缺口卡正文(签名没变时沿用,避免换个数字重发)
104
+ messageCount: -1, // 上一次见效时的消息条数(压缩判据)
105
+ baselineDone: false, // 本代是否已经给过 baseline
106
+ })
107
+ return {
108
+ /**
109
+ * 处理代次变化,取回本会话这一代的起点。**必须在算正文与控制提示之前调用。**
110
+ * 返回值是一份快照 —— 调用方只读,写回统一走 `commit()`。
111
+ */
112
+ begin(key, messageCount) {
113
+ const prev = sessions.get(key)
114
+ const generationChanged = !!prev && messageCount < prev.messageCount
115
+ // 条数下降 → 四样东西一起归零。少清任何一样都会留下一个「上一代的真相」:
116
+ // 字节账残留 → 新内容注不进来;baseline 残留 → 它再也不出现;
117
+ // 卡签名残留 → 恢复入口永久失效。
118
+ if (generationChanged) sessions.set(key, { ...fresh(), messageCount })
119
+ const cur = sessions.get(key) ?? fresh()
120
+ return { ...cur, generationChanged }
121
+ },
122
+ /** 一次写回。只防长驻进程无限增长:被挤掉的老会话下次醒来至多把同一批记忆再注一次。 */
123
+ commit(key, next) {
124
+ sessions.set(key, { ...next })
125
+ // FIFO 而不是 LRU:Map 重写已存在的键不会挪动位置,与旧行为一致。
126
+ if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
127
+ },
128
+ }
129
+ }
130
+
78
131
  /**
79
132
  * 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
80
133
  * 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
@@ -354,31 +407,17 @@ export class MemoryGateway extends TypertRemoteService {
354
407
  }
355
408
  } catch { /* 老宿主没有这两个事件:写入按宿主默认语义走 */ }
356
409
 
357
- // 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
358
- // 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
359
- const recallCache = new Map()
360
-
361
- // 「本会话是否已注入过 baseline」——会话级状态,按 session id 记。
362
- //
363
- // 为什么不是消息条数:见下面 baseline 那段注释(恢复会话的历史消息早就在
364
- // session 里,条数永不为 0)。会话 id 在整场会话里稳定,与 assemble 时
365
- // 当前用户消息有没有落盘完全无关,所以新会话、恢复会话两种时序都成立。
366
- // 容量 64 与 recallCache 同理,只防长驻进程无限增长:要挤掉一个 id 得先有
367
- // 63 场别的会话开起来,那场老会话再被唤醒时最多是把同样的 baseline 再注入一次。
368
- const BASELINE_CACHE_MAX = 64
369
- const baselineInjected = new Set()
370
-
371
- // 「本会话已经注入掉多少字节」—— 会话级账本,按 session id 记。
410
+ // 会话级注入状态(检索签名 / baseline 标记 / 两本账 / 卡签名)——
411
+ // 一处持有、一处提交,理由与顺序见 `createSessionInjectState` 的注释。
372
412
  //
373
- // 为什么不扫会话记录去算:同一轮 assemble 出来的内容会被宿主合进**同一条**
374
- // user/message(运行时上下文、工作区指令、插件注入共用一条),按消息字节统计
375
- // 会把别人的内容算进本插件的账;按文本找标记又太脆。自己记账是确定性最高的。
413
+ // 为什么不扫会话记录去算「已经注掉多少字节」:同一轮 assemble 出来的内容会被宿主
414
+ // 合进**同一条** user/message(运行时上下文、工作区指令、插件注入共用一条),
415
+ // 按消息字节统计会把别人的内容算进本插件的账;按文本找标记又太脆。自己记账确定性最高。
376
416
  //
377
- // 为什么要连 messageCount 一起记:压缩 / 清空之后上下文里的记忆已经没了,而
378
- // session id 不变 —— 只按 id 记账会让「预算用满」变成永久状态,压缩之后再也
379
- // 注不进来。消息条数变小是压缩发生过的最直接信号,用它把账本归零。
380
- const INJECT_LEDGER_MAX = 64
381
- const injectLedger = new Map()
417
+ // baseline 为什么按会话记而不是按消息条数:恢复会话的历史消息早就在 session 里,
418
+ // 条数永不为 0。会话 id 在整场会话里稳定,与 assemble 时当前用户消息有没有落盘无关,
419
+ // 所以新会话、恢复会话两种时序都成立。
420
+ const injectState = createSessionInjectState()
382
421
 
383
422
  // 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
384
423
  // 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
@@ -410,6 +449,14 @@ export class MemoryGateway extends TypertRemoteService {
410
449
  const text = extractText(userMessages[userMessages.length - 1]?.content)
411
450
 
412
451
  const files = await scanMemoryFiles(ctx, sessionId)
452
+
453
+ // ── 代次:**先处理状态转换,再算正文与控制提示** ──
454
+ // 压缩 / 清空(消息条数下降)在这一行里就让签名、两本账、卡签名、baseline 标记
455
+ // 一起归零。放到后面(比如去重之后再算)就等于「拿上一代的账本判这一代」——
456
+ // 复审 R03 的「同题压缩后仍被挡住」正是这么来的。
457
+ const ledgerKey = session.id || 'no-session-id'
458
+ const st = injectState.begin(ledgerKey, messages.length)
459
+
413
460
  const picked = []
414
461
  const seen = new Set()
415
462
  const take = (f) => {
@@ -431,22 +478,17 @@ export class MemoryGateway extends TypertRemoteService {
431
478
  // 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
432
479
  // 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
433
480
  let baselineTaken = 0
434
- const baselineKey = session.id ? session.id : session
435
- if (!baselineInjected.has(baselineKey)) {
481
+ if (!st.baselineDone) {
436
482
  selectBaseline(files)
437
483
  .forEach(f => {
438
484
  const before = picked.length
439
485
  take(f)
440
486
  if (picked.length > before) baselineTaken++
441
487
  })
442
- // 只有真取到才落标记:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
488
+ // 只有真取到才算「给过」:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
443
489
  // 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
444
- if (baselineTaken > 0) {
445
- baselineInjected.add(baselineKey)
446
- if (baselineInjected.size > BASELINE_CACHE_MAX) {
447
- baselineInjected.delete(baselineInjected.values().next().value)
448
- }
449
- }
490
+ // 标记本身随这一步一起在 commit() 落 —— 与「提交放最后」同一条理由:
491
+ // 没真注进去的东西不能记成「已经给过」。
450
492
  }
451
493
 
452
494
  // 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
@@ -505,16 +547,15 @@ export class MemoryGateway extends TypertRemoteService {
505
547
  }
506
548
 
507
549
  const signature = text + '\u0000' + selected.map(f => f.file).join(',')
508
- if (recallCache.get(session.id) !== signature) {
509
- recallCache.set(session.id, signature)
510
- if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
511
- selected.forEach(take)
512
- }
550
+ // 检索签名没变 = 这一代里这条问题已经处理过 → 不再重复追加同一份记忆。
551
+ // ⚠️ 这个判断必须排在代次处理**之后**:压缩后的同题恢复,靠的就是上面 `begin()`
552
+ // 把签名一起清掉(复审 R03)。
553
+ const deduped = st.signature === signature
554
+ if (!deduped) selected.forEach(take)
513
555
 
514
- if (picked.length === 0) {
515
- if (DEBUG_RECALL) debugRecall({ scanned: files.length, hits: relevant.length, scores: [], picked: [], injected: [], chars: 0, skippedByBudget: 0 })
516
- return assembled
517
- }
556
+ // ⚠️ 这里**不能**再写「picked 为空就返回」:检查点句柄与未命中提示都不依赖候选,
557
+ // 提前返回会让它们在「目录为空」或「零命中」时永远不可达(复审 R02)。
558
+ // 统一的出口在下面 —— 只有**正文与控制提示都为空**才返回。
518
559
 
519
560
  // ── 单会话累计预算:**拆成两本账** ──
520
561
  // 正文池 = CONTENT_BUDGET(总上限 − 状态卡预留)/卡池 = STATUS_RESERVE
@@ -522,15 +563,10 @@ export class MemoryGateway extends TypertRemoteService {
522
563
  // 都没有;而那个上限是**本插件自己定的**,完全可以给状态卡留一份固定额度。
523
564
  // 账记在 session id 上;picked 里 baseline 永远排在检索结果之前,所以预算不够时
524
565
  // 先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
525
- const ledgerKey = session.id || 'no-session-id'
526
- const prevLedger = injectLedger.get(ledgerKey)
527
- // 压缩 / 清空 → **两本账与卡签名一起归零**。卡签名必须一起清:不然「本会话已经发过
528
- // 这张卡」仍然成立,而那张卡早已随旧上下文消失 —— 恢复入口就此永久失效。
529
- // (「本会话注入过」不等于「压缩后还看得见」,这条是计划里点名要处理的。)
530
- const carried = prevLedger && messages.length >= prevLedger.messageCount ? prevLedger : null
531
- let contentBytes = carried?.contentBytes ?? 0
532
- let cardBytes = carried?.cardBytes ?? 0
533
- let cardSig = carried?.cardSig ?? ''
566
+ // 起点来自 `begin()`:它可能刚因压缩把两本账连同卡签名一起归零过。
567
+ let contentBytes = st.contentBytes
568
+ let cardBytes = st.cardBytes
569
+ let cardSig = st.cardSig
534
570
  const nowMs = Date.now()
535
571
 
536
572
  // ── 渲染候选,然后交给展示层决定「给正文 / 给指针 / 不给」──
@@ -634,19 +670,27 @@ export class MemoryGateway extends TypertRemoteService {
634
670
  } catch { /* 读不到检查点就当没有:它是可选的接力棒,不是必需件 */ }
635
671
 
636
672
  const wantCard = gapExists || intentInfo.historyDependent || !!checkpointLine
637
- const cardBody = wantCard
638
- ? renderStatusCard({
639
- scope: bodySearched ? 'all' : 'summary',
640
- scanned: files.length,
641
- injected: parts.length,
642
- omitted: skippedForBudget,
643
- unread,
644
- best: beacon ? { file: beacon.file, title: beacon.chunk?.title || beacon.chunk?.headingPath || '' } : null,
645
- needle: text ? text.slice(0, 40) : null,
646
- toolsAvailable: toolsReady,
647
- memoryDir: MEMORY_DIR,
648
- })
649
- : ''
673
+ // 签名没变时沿用上一步渲染好的那份缺口卡,而不是重渲染一张。
674
+ // 两条理由:其一,那一步与这一步的检索面**逐字相同**,缺口也相同,重渲染只会把
675
+ // 同一件事换个数字再说一遍 —— 而局部变量算出来的数字还是错的(上一步注了 N 条,
676
+ // 这一步按本轮 parts 算是 0 条);其二,文本一变 `cardSig` 去重就失效,同一张语义
677
+ // 的卡会被反复追加进会话。
678
+ // 检查点句柄不在此列:它是可能刚刚才出现的接力棒,所以始终参与合成。
679
+ const cardBody = !wantCard
680
+ ? ''
681
+ : deduped
682
+ ? st.gapCard
683
+ : renderStatusCard({
684
+ scope: bodySearched ? 'all' : 'summary',
685
+ scanned: files.length,
686
+ injected: parts.length,
687
+ omitted: skippedForBudget,
688
+ unread,
689
+ best: beacon ? { file: beacon.file, title: beacon.chunk?.title || beacon.chunk?.headingPath || '' } : null,
690
+ needle: text ? text.slice(0, 40) : null,
691
+ toolsAvailable: toolsReady,
692
+ memoryDir: MEMORY_DIR,
693
+ })
650
694
  // 句柄与卡**共用卡池**:它是同一个通道里的另一句提示,不该另开一份额度。
651
695
  const cardText = [cardBody, checkpointLine].filter(Boolean).join('\n')
652
696
  let cardPart = ''
@@ -674,10 +718,18 @@ export class MemoryGateway extends TypertRemoteService {
674
718
  return assembled
675
719
  }
676
720
 
677
- injectLedger.set(ledgerKey, { contentBytes, cardBytes, cardSig, messageCount: messages.length })
678
- if (injectLedger.size > INJECT_LEDGER_MAX) {
679
- injectLedger.delete(injectLedger.keys().next().value)
680
- }
721
+ // 状态一次写回:签名、两本账、卡签名、缺口卡、条数、baseline 标记。
722
+ // 放在这里(而不是更早)是刻意的 —— 上面每一步都可能没真注进去东西,
723
+ // 那就不该让状态前进;一次失败把一条记忆永久挡在门外是最难查的一种。
724
+ injectState.commit(ledgerKey, {
725
+ signature,
726
+ contentBytes,
727
+ cardBytes,
728
+ cardSig,
729
+ gapCard: cardBody,
730
+ messageCount: messages.length,
731
+ baselineDone: st.baselineDone || baselineTaken > 0,
732
+ })
681
733
 
682
734
  if (DEBUG_RECALL) {
683
735
  debugRecall({
@@ -19,7 +19,10 @@ export const SESSIONS_ROOT = process.env.DSH_HOME ? join(process.env.DSH_HOME, '
19
19
 
20
20
  /** 读一个整数型环境变量并在范围内收敛;取不到或非法就用默认值。 */
21
21
  export function envInt(name, dflt, lo, hi) {
22
- const raw = process.env[name]
22
+ // ⚠️ 必须先 trim:`Number(' ')` 是 **0** 而不是 NaN,于是纯空格的值会被
23
+ // 当成「用户填了 0」——低于下限、被夹到 lo,静默变成「最小而不是默认」。
24
+ // 实测过:`SAGE_MEM_MAX_RESULTS=" "` → 1。那与这行注释承诺的「非法就用默认值」相反。
25
+ const raw = process.env[name]?.trim()
23
26
  if (raw === undefined || raw === '') return dflt
24
27
  const n = Number(raw)
25
28
  if (!Number.isFinite(n)) return dflt
@@ -14,6 +14,7 @@ import { MAX_FILE_BYTES, MEMORY_DIR } from './config.js'
14
14
  import { parseBaseline, parseFrontmatter } from './frontmatter.js'
15
15
  import { isMemoryEntry, isReserved } from './naming.js'
16
16
  import { warnLog } from './util.js'
17
+ import { createDirWatcher } from './watch.js'
17
18
  import { lstat, readFile, readdir } from 'node:fs/promises'
18
19
  import { join } from 'node:path'
19
20
 
@@ -235,12 +236,135 @@ export async function inspectDir(dir) {
235
236
  return { ok: true }
236
237
  }
237
238
 
238
- /** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
239
+ /**
240
+ * 进程内扫描缓存,**按目录分账**。
241
+ *
242
+ * 为什么按目录分:`scanMemoryFiles` 用的是模块级 `MEMORY_DIR`,但「扫一个目录」这件事本身
243
+ * 与目录无关(`scanMemoryDir` 允许显式给一个)。两个目录的失效信号、文件名集合、文件内容
244
+ * 都不该互相污染 —— 与 D18「台账跟着它依据的目录走」同一条道理。串了账的缓存比没有缓存
245
+ * 更危险:它会拿 A 目录的内容去回答关于 B 目录的问题。
246
+ *
247
+ * 每条:`{generation, watcher, entry}`
248
+ * - `generation` —— watcher 送来的失效信号计数,只增不减。
249
+ * - `watcher` —— `createDirWatcher` 的句柄;**`ok === false` 时一律不缓存**。
250
+ * - `entry` —— `{generation, namesKey, files}`,命中要同时满足代次相同与文件名集合相同。
251
+ */
252
+ const scanStates = new Map()
253
+
254
+ /** 目录名集合的指纹:排序后拼接。新增 / 删除 / 重命名都会换指纹。 */
255
+ function namesKeyOf(names) {
256
+ return [...names].sort().join('\n')
257
+ }
258
+
259
+ /** 取(必要时建)某个目录的缓存账与它的失效信号源。 */
260
+ function scanStateOf(dir) {
261
+ let st = scanStates.get(dir)
262
+ if (!st) {
263
+ st = { generation: 0, watcher: null, entry: null }
264
+ scanStates.set(dir, st)
265
+ }
266
+ /**
267
+ * watcher 起不来时**每轮再试一次**,而不是永久禁用:记忆目录可能在插件加载时还不存在
268
+ * (首扫落空)、之后才被建出来,永久禁用会让这一整个进程都拿不到加速。失败一次的代价
269
+ * 只是一次系统调用级的失败(相对全量扫描可忽略)。
270
+ */
271
+ if (!st.watcher || !st.watcher.ok) {
272
+ if (st.watcher) st.watcher.dispose()
273
+ /**
274
+ * 换失效信号源时把缓存一起丢掉。旧 watcher 死掉(报错 / 洪流哨兵)这件事本身说明
275
+ * 「它最后一次送信号之后的这段时间里目录可能变过」,留着旧条目就是拿一段无据可依的
276
+ * 历史继续供内容。代价只是多一次全扫,而换 watcher 是罕见事件。
277
+ */
278
+ st.entry = null
279
+ // 失效信号只做一件事:代次 +1。去抖在 watcher 内部完成(首个事件立刻、窗口末尾再一次)。
280
+ st.watcher = createDirWatcher(dir, () => {
281
+ st.generation++
282
+ })
283
+ }
284
+ return st
285
+ }
286
+
287
+ /**
288
+ * 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。
289
+ *
290
+ * A2 之后本函数**可能命中进程内缓存**:目录真的没变时,直接把上一次的结果还给你,
291
+ * 一份文件都不打开。失效判据与残余风险见 `scanMemoryDir`。
292
+ */
239
293
  export async function scanMemoryFiles(ctx, sessionId) {
294
+ return scanMemoryDir(ctx, sessionId, MEMORY_DIR)
295
+ }
296
+
297
+ /**
298
+ * 同 `scanMemoryFiles`,但目录可显式给(缓存按目录分账;测试用它证明两个目录不互相污染)。
299
+ *
300
+ * 失效判据**两层**,缺一不可:
301
+ * 1. **主判据 —— watcher 代次**:目录里发生过事件就重扫。它也是「只改内容、不改名」这类
302
+ * 变更**唯一**的失效来源 —— 文件名集合看不出内容变没变。
303
+ * 2. **兜底判据 —— 文件名集合**:每次仍做一次 `readdir`(1000 份实测 0.7 ms)比集合。
304
+ * 它防的是 watcher 最可能漏的那一类:**新增 / 删除 / 重命名**(漏事件时它仍然准确)。
305
+ *
306
+ * ⚠️ **残余风险(有意的取舍,不是疏忽)**:watcher 漏掉「只改内容、不改文件名」的事件时,
307
+ * 本进程会继续供旧内容,直到下一次任何失效。为什么接受:想独立复核内容变没变,只能逐份
308
+ * `lstat` 比 mtime,而那是这次要省掉的主要成本(1000 份实测 83 ms)—— 要复核就等于放弃加速。
309
+ * 后果是**一次注入拿到旧内容**,不是永久错误:文件一旦被增 / 删 / 改名,兜底判据立刻恢复正确,
310
+ * 下一次侥幸收到的事件也会。漏事件概率主要落在「外部程序原地覆写文件」这类姿势上:
311
+ * 插件自己的写入路径(设置页 / 工具)走的是原子写(临时名 + rename),必然触发事件。
312
+ *
313
+ * watcher 不可用(起不来 / 报错)时**一律全扫**,与加缓存之前逐字一致 —— 拿不到失效信号
314
+ * 就不许缓存,这是这条优化的安全底线。
315
+ */
316
+ export async function scanMemoryDir(ctx, sessionId, dir = MEMORY_DIR) {
317
+ const st = scanStateOf(dir)
318
+ // ── 命中检查:代次 + 文件名集合,两道都对上才算「目录真的没变」──
319
+ if (st.watcher.ok && st.entry) {
320
+ const g0 = st.generation
321
+ if (st.entry.generation === g0) {
322
+ const names = await readdir(dir).catch(() => null)
323
+ /**
324
+ * 校验期间若又来了事件(代次变了)就当没命中。与落缓存时同一条理由:我无法证明
325
+ * 「刚读到的这份指纹」与「刚比过的那份缓存」描述的是同一瞬间的那个目录。
326
+ */
327
+ if (names && namesKeyOf(names) === st.entry.namesKey && st.generation === g0) {
328
+ // 还一个新数组:缓存是共享的,调用方就地排序 / splice 不该改到别人的那一份。
329
+ return st.entry.files.slice()
330
+ }
331
+ }
332
+ }
333
+ // ── 未命中:全扫(与加缓存之前逐字一致),只在「扫描期间没有任何变更」时才落缓存 ──
334
+ const gStart = st.generation
335
+ const scanned = await fullScanMemoryDir(ctx, sessionId, dir)
336
+ const gEnd = st.generation
337
+ st.entry = null
338
+ /**
339
+ * 连目录都列不出来(不存在 / 无权限)→ 这个失效信号源已经不可信,就地关掉。
340
+ * 为什么必须这么干:句柄在目录消失后会变成**空转的**(每秒十几万个 'change',
341
+ * 且不发 'error',见 watch.js 的洪流哨兵)—— 主动关掉比等哨兵兜底更早、也更直白。
342
+ * 关掉之后下一轮扫描会重新起表:目录回来了就继续用,回不来就是 fs.watch 抛错 → 全扫。
343
+ */
344
+ if (scanned.namesKey === null && st.watcher) {
345
+ st.watcher.dispose()
346
+ st.watcher = null
347
+ }
348
+ if (st.watcher && st.watcher.ok && scanned.namesKey !== null && gStart === gEnd) {
349
+ st.entry = { generation: gEnd, namesKey: scanned.namesKey, files: scanned.files }
350
+ }
351
+ // 也还新数组:上面刚把同一份引用存进了缓存,交出去就等于把缓存的所有权给了调用方。
352
+ return scanned.files.slice()
353
+ }
354
+
355
+ /**
356
+ * 真的走一遍磁盘。返回 `{files, namesKey}`。
357
+ *
358
+ * `namesKey === null` 表示目录根本没列出来(不存在 / 无权限)→ 这一轮**不落缓存**:
359
+ * 把一次失败当成「目录是空的」存下来,会让之后每一次读取都命中这份假结果。
360
+ */
361
+ async function fullScanMemoryDir(ctx, sessionId, dir) {
240
362
  try {
241
- const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
363
+ const entries = await readdir(dir, { withFileTypes: true })
364
+ // 目录名集合的指纹:作为缓存的**兜底判据**(watcher 漏掉增 / 删 / 改名时的第二道闸)。
365
+ const namesKey = namesKeyOf(entries.map(e => e.name))
242
366
  /** 保留名的文件身份:8.3 短名与硬链接都得靠它才认得出(名字文本上它们毫无关系)。 */
243
- const reservedIds = await reservedIdentitySet()
367
+ const reservedIds = await reservedIdentitySet(dir)
244
368
  /**
245
369
  * 链接条目在 `isFile()` 这一步就被滤掉了(`readdir` 的 Dirent 报的是链接本身),
246
370
  * 也就是说**闸门根本没机会看到它们** —— 不在这里出声,用户只会看到「这条记忆不见了」。
@@ -254,7 +378,7 @@ export async function scanMemoryFiles(ctx, sessionId) {
254
378
  .filter(e => e.isFile() && isMemoryEntry(e.name))
255
379
  .map(e => e.name)
256
380
  const loaded = await Promise.all(names.map(async (name) => {
257
- const full = join(MEMORY_DIR, name)
381
+ const full = join(dir, name)
258
382
  // 先过闸门(lstat + 体积),再读:链接与超大文件都在**全量读之前**被挡下,
259
383
  // 而不是读进来之后才发现不该读。mtime 由闸门一并给出,比原来少一跳 stat。
260
384
  const gate = await inspectMemoryEntry(full)
@@ -295,10 +419,10 @@ export async function scanMemoryFiles(ctx, sessionId) {
295
419
  return null
296
420
  }
297
421
  }))
298
- return loaded.filter(Boolean)
422
+ return { files: loaded.filter(Boolean), namesKey }
299
423
  } catch (err) {
300
- if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`, sessionId)
301
- return []
424
+ if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${dir}: ${err?.message ?? err}`, sessionId)
425
+ return { files: [], namesKey: null }
302
426
  }
303
427
  }
304
428