sage-mem 1.9.2 → 2.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.
@@ -31,10 +31,18 @@ export const CONFLICTS_DIR_NAME = 'conflicts'
31
31
  export const CONFLICT_FILE_SUFFIX = '.json'
32
32
 
33
33
  /**
34
- * 冲突的种类。`stale_version` 是宿主版本守卫直接报出来的那种(最常见);
35
- * `external_change` 是文件在别处被改了;`unresolved_merge` 是人工合并到一半留下的。
34
+ * 冲突的种类。
35
+ *
36
+ * 前三类是**写失败**型:盘上那份仍是现行内容(`stale_version` 是宿主版本守卫直接报出来的那种,
37
+ * 最常见;`external_change` 是文件在别处被改了;`unresolved_merge` 是人工合并到一半留下的)。
38
+ * 第四类 `contradiction` 是**语义**型:两条记忆的说法互斥,盘上两份都是「真的」,
39
+ * 只有人(或模型整理时)能判断哪条不再作数。
40
+ *
41
+ * ⚠️ 这个区分不是命名洁癖:注入侧的抑制**只对 `contradiction` 生效**。
42
+ * 把写失败型也塞进抑制集合,等于把「这次没写进去」误报成「这条记忆不可信」——
43
+ * 盘上那份明明还是有效的,见 docs/decisions.md 的 D27 / D29。
36
44
  */
37
- export const CONFLICT_KINDS = Object.freeze(['stale_version', 'external_change', 'unresolved_merge'])
45
+ export const CONFLICT_KINDS = Object.freeze(['stale_version', 'external_change', 'unresolved_merge', 'contradiction'])
38
46
 
39
47
  /**
40
48
  * 记录的状态。`open` 是唯一「待处理」的状态;另外两种都是人的判断结果,
@@ -62,6 +70,14 @@ export const CONFLICT_FIELD_LIMITS = Object.freeze({
62
70
  incoming: 48 * 1024,
63
71
  onDisk: 48 * 1024,
64
72
  file: 1024,
73
+ // 2.0.0 追加字段(只增不改,`v` 仍是 1):`otherFile` 与 `file` 同量级(文件名);
74
+ // `decision` / `decidedAt` / `appliedVersion` 是裁决收据,都是短串;
75
+ // `invalidatedFile` 记「这次被标失效的是哪一方」——它是 `undo` 唯一可信的落点。
76
+ otherFile: 1024,
77
+ invalidatedFile: 1024,
78
+ decision: 32,
79
+ decidedAt: 64,
80
+ appliedVersion: 256,
65
81
  note: 4096,
66
82
  tool: 256,
67
83
  version: 256,
@@ -216,6 +232,13 @@ export function buildConflict(input) {
216
232
  const kind = typeof src.kind === 'string' ? src.kind : ''
217
233
 
218
234
  const file = clampText(src.file, CONFLICT_FIELD_LIMITS.file)
235
+ // 2.0.0 追加的字段全部**尾部追加**、且都是字符串:老版本读到不认识的键会忽略,
236
+ // 新版本读老记录时它们缺省为空串 —— 双向兼容,不需要迁移脚本。
237
+ const otherFile = clampText(src.otherFile, CONFLICT_FIELD_LIMITS.otherFile)
238
+ const invalidatedFile = clampText(src.invalidatedFile, CONFLICT_FIELD_LIMITS.invalidatedFile)
239
+ const decision = clampText(src.decision, CONFLICT_FIELD_LIMITS.decision)
240
+ const decidedAt = normalizeAt(src.decidedAt)
241
+ const appliedVersion = clampText(versionText(src.appliedVersion), CONFLICT_FIELD_LIMITS.appliedVersion)
219
242
  const incoming = clampText(src.incoming, CONFLICT_FIELD_LIMITS.incoming)
220
243
  const onDisk = clampText(src.onDisk, CONFLICT_FIELD_LIMITS.onDisk)
221
244
  const note = clampText(src.note, CONFLICT_FIELD_LIMITS.note)
@@ -224,10 +247,15 @@ export function buildConflict(input) {
224
247
  const actualVersion = clampText(versionText(src.actualVersion), CONFLICT_FIELD_LIMITS.version)
225
248
 
226
249
  const truncated =
227
- file.cut || incoming.cut || onDisk.cut || note.cut || tool.cut || expectedVersion.cut || actualVersion.cut
250
+ file.cut || otherFile.cut || invalidatedFile.cut || decision.cut || appliedVersion.cut ||
251
+ incoming.cut || onDisk.cut || note.cut || tool.cut || expectedVersion.cut || actualVersion.cut
228
252
 
229
253
  const stamp = compactStamp(at)
230
- const digest = shortDigest([kind, file.text, expectedVersion.text, actualVersion.text, incoming.text, onDisk.text])
254
+ // ⚠️ `otherFile` **只在非空时**进指纹:空串也拼进去的话,所有 1.x 老种类的 id 都会变 ——
255
+ // 老记录还在盘上、新记录的 id 换了,就成了「同一件事两个身份」。
256
+ const digestParts = [kind, file.text, expectedVersion.text, actualVersion.text, incoming.text, onDisk.text]
257
+ if (otherFile.text) digestParts.push(otherFile.text)
258
+ const digest = shortDigest(digestParts)
231
259
  const id = normalizeConflictId(src.id) ?? `${stamp === '' ? '' : `${stamp}-`}${digest}`
232
260
 
233
261
  return {
@@ -244,6 +272,11 @@ export function buildConflict(input) {
244
272
  note: note.text,
245
273
  tool: tool.text,
246
274
  truncated,
275
+ otherFile: otherFile.text,
276
+ decision: decision.text,
277
+ decidedAt,
278
+ appliedVersion: appliedVersion.text,
279
+ invalidatedFile: invalidatedFile.text,
247
280
  }
248
281
  }
249
282
 
@@ -343,6 +376,8 @@ export async function writeConflict(record, opts) {
343
376
  } catch (err) {
344
377
  return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
345
378
  }
379
+ // 自己写的 → 注入侧那份摘要缓存立刻失效(否则面板裁决完,下一步仍按旧索引抑制一次)。
380
+ invalidateConflictIndex(opts && typeof opts === 'object' ? opts.stateRoot : undefined)
346
381
  return { ok: true, bytes: Buffer.byteLength(text, 'utf8'), path, truncated }
347
382
  }
348
383
 
@@ -405,6 +440,69 @@ function cmp(a, b) {
405
440
  return x < y ? -1 : x > y ? 1 : 0
406
441
  }
407
442
 
443
+ /**
444
+ * 注入侧用的**轻量索引缓存**(2.0.0,plan §6.6 的阈值触发的那个小缓存)。
445
+ *
446
+ * 为什么需要(实测):100 条记录、每份两份正文各 8 KB 时,`listConflicts` 单次 **p50 78 ms**
447
+ * —— 而它在**每一步 assemble** 都会被调一次。把两份正文一起读进 JSON.parse 是不必要的:
448
+ * 注入只需要 `kind / status / file / otherFile`。
449
+ *
450
+ * 三条失效判据(缺一不可):
451
+ * 1. TTL(1 秒):外部编辑器改记录也能在 1 秒内被看到;
452
+ * 2. **本模块自己写的**(落盘 / 裁决 / 删除)立即清缓存 —— 面板点完下一步就该生效;
453
+ * 3. 缓存键是目录绝对路径(`SAGE_MEM_STATE_DIR` 变了就是另一份缓存)。
454
+ *
455
+ * 1 秒的陈旧窗口是**有意的取舍**:注入是「每一两步一次」的读,不是事务;
456
+ * 代价上限是「刚裁决完的一步里,那条记忆仍被抑制一次」,而收益是每步省几十毫秒。
457
+ */
458
+ const INDEX_CACHE = new Map()
459
+
460
+ /** 注入侧缓存有效期。改这里要同时想清楚「用户点完面板多久该见效」。 */
461
+ export const CONFLICT_INDEX_TTL_MS = 1000
462
+
463
+ /** 这组文件的**摘要索引**:不带两份正文,给注入链用。 */
464
+ export async function listConflictIndex(opts) {
465
+ const dir = conflictsDir(opts && typeof opts === 'object' ? opts.stateRoot : undefined)
466
+ const now = Date.now()
467
+ const cached = INDEX_CACHE.get(dir)
468
+ if (cached && now - cached.at < CONFLICT_INDEX_TTL_MS) return { ok: true, items: cached.items }
469
+
470
+ let names
471
+ try {
472
+ names = await readdir(dir)
473
+ } catch (err) {
474
+ if (err?.code === 'ENOENT') {
475
+ INDEX_CACHE.delete(dir)
476
+ return { ok: true, items: [] }
477
+ }
478
+ return { ok: false, code: 'io_error', dir, error: String(err?.message ?? err) }
479
+ }
480
+
481
+ const items = []
482
+ for (const name of [...names].sort(cmp)) {
483
+ if (!name.endsWith(CONFLICT_FILE_SUFFIX)) continue
484
+ const id = name.slice(0, -CONFLICT_FILE_SUFFIX.length)
485
+ if (normalizeConflictId(id) === null) continue
486
+ let text
487
+ try {
488
+ text = await readFile(join(dir, name), 'utf8')
489
+ } catch {
490
+ continue
491
+ }
492
+ const parsed = parseConflictText(text)
493
+ if (!parsed.ok) continue
494
+ items.push(summarizeConflict({ ...parsed.record, id }))
495
+ }
496
+ items.sort((a, b) => cmp(String(b.at ?? ''), String(a.at ?? '')) || cmp(a.id, b.id))
497
+ INDEX_CACHE.set(dir, { at: now, items })
498
+ return { ok: true, items }
499
+ }
500
+
501
+ /** 本模块自己改了队列 → 立刻丢掉这份缓存(下一次读重新扫目录)。 */
502
+ function invalidateConflictIndex(stateRoot) {
503
+ INDEX_CACHE.delete(conflictsDir(stateRoot))
504
+ }
505
+
408
506
  /**
409
507
  * 列出全部冲突(只读目录)。
410
508
  *
@@ -462,41 +560,145 @@ export async function listConflicts(opts) {
462
560
  }
463
561
 
464
562
  /**
465
- * 改状态并落盘。
563
+ * 记录的**摘要**(不含两份正文)。
466
564
  *
467
- * 状态机见 `CONFLICT_TRANSITIONS`:`open` 只能去 `resolved` / `dismissed`,那两个之间可以互转,
468
- * **都不许回到 `open`**(要重开就新建一条)。同状态重复点是**幂等**的 —— 界面上连点两下
469
- * 不该报错,也不重写文件。
565
+ * 面板列表只需要元数据;把 `incoming` / `onDisk`(各上限 48 KB)一起送过去,
566
+ * 一次列表就可能把几十兆正文推过 remote 边界。要看正文走 `read`(每次一条)。
567
+ */
568
+ export function summarizeConflict(record) {
569
+ const r = record && typeof record === 'object' ? record : {}
570
+ return {
571
+ id: typeof r.id === 'string' ? r.id : '',
572
+ at: typeof r.at === 'string' ? r.at : '',
573
+ kind: typeof r.kind === 'string' ? r.kind : '',
574
+ status: typeof r.status === 'string' ? r.status : '',
575
+ file: typeof r.file === 'string' ? r.file : '',
576
+ otherFile: typeof r.otherFile === 'string' ? r.otherFile : '',
577
+ note: typeof r.note === 'string' ? r.note : '',
578
+ decision: typeof r.decision === 'string' ? r.decision : '',
579
+ decidedAt: typeof r.decidedAt === 'string' ? r.decidedAt : '',
580
+ appliedVersion: typeof r.appliedVersion === 'string' ? r.appliedVersion : '',
581
+ invalidatedFile: typeof r.invalidatedFile === 'string' ? r.invalidatedFile : '',
582
+ truncated: r.truncated === true,
583
+ }
584
+ }
585
+
586
+ /** 改记录时可以写的字段(白名单:不是「把 patch 合并进去」,免得远程调用能改写 id / 正文)。 */
587
+ const PATCHABLE_TEXT = Object.freeze(['decision', 'appliedVersion', 'invalidatedFile', 'otherFile', 'note'])
588
+
589
+ /**
590
+ * 「这两条记录是不是同一件事」—— 去重的**唯一判据**(纯函数,`autodream/tools.js` 与
591
+ * 测试共用;D22:同一判据只许一份实现)。
470
592
  *
471
- * @returns {Promise<{ok:true, record:object} | {ok:false, code:'invalid'|'invalid_transition'|'missing'|'malformed'|'io_error', error?}>}
593
+ * 逐字段相等:`status` / `kind` / `file` / `otherFile` / 两个版本 / 两份正文。
594
+ * 不带 `note` / `tool` / `at` / `decision`:它们描述「谁在什么时候碰上的」,
595
+ * 不是「冲突本身」。`otherFile` 缺省按空串比较(1.9.x 老记录没有这个键)。
472
596
  */
473
- export async function resolveConflict(id, status, opts) {
597
+ export function sameConflict(a, b) {
598
+ const x = a && typeof a === 'object' ? a : {}
599
+ const y = b && typeof b === 'object' ? b : {}
600
+ return (
601
+ x.status === y.status &&
602
+ x.kind === y.kind &&
603
+ x.file === y.file &&
604
+ (x.otherFile ?? '') === (y.otherFile ?? '') &&
605
+ x.expectedVersion === y.expectedVersion &&
606
+ x.actualVersion === y.actualVersion &&
607
+ x.incoming === y.incoming &&
608
+ x.onDisk === y.onDisk
609
+ )
610
+ }
611
+
612
+ /**
613
+ * 改一条记录的字段(2.0.0 的裁决入口专用;`resolveConflict` 是它的子集)。
614
+ *
615
+ * 与 `resolveConflict` 同一条状态机:同状态重复点是幂等的(不重写文件),
616
+ * 已判定过的记录不许回到 `open`。**先改文件、后改记录**的顺序由调用方保证
617
+ * (见 `ConflictGateway.invalidate`)—— 反过来的半态是「记录说已裁决、文件没改」,
618
+ * 用户再也看不到它。
619
+ *
620
+ * @returns {Promise<{ok:true, record:object}
621
+ * | {ok:false, code:'invalid'|'invalid_transition'|'missing'|'malformed'|'io_error', error?}>}
622
+ */
623
+ export async function patchConflict(id, patch, opts) {
474
624
  const cid = normalizeConflictId(id)
475
625
  if (cid === null) return { ok: false, code: 'invalid', error: `冲突 id 非法:${JSON.stringify(id)}` }
476
- if (!CONFLICT_STATUSES.includes(status)) {
477
- return { ok: false, code: 'invalid', error: `未知状态:${JSON.stringify(status)}` }
626
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
627
+ return { ok: false, code: 'invalid', error: '冲突补丁必须是一个对象' }
478
628
  }
479
629
 
480
630
  const read = await readConflict(cid, opts)
481
631
  if (!read.ok) return { ok: false, code: read.code, error: read.error }
632
+ const cur = read.record
633
+ const next = { ...cur }
634
+ let changed = false
635
+
636
+ if (patch.status !== undefined) {
637
+ if (!CONFLICT_STATUSES.includes(patch.status)) {
638
+ return { ok: false, code: 'invalid', error: `未知状态:${JSON.stringify(patch.status)}` }
639
+ }
640
+ if (patch.status !== cur.status) {
641
+ const allowed = CONFLICT_TRANSITIONS[cur.status] ?? []
642
+ if (!allowed.includes(patch.status)) {
643
+ return {
644
+ ok: false,
645
+ code: 'invalid_transition',
646
+ error: `${cur.status} → ${patch.status} 不被允许(已判定过的记录不许回到 open,要重开请新建一条)`,
647
+ }
648
+ }
649
+ next.status = patch.status
650
+ changed = true
651
+ }
652
+ }
482
653
 
483
- const from = read.record.status
484
- if (from === status) return { ok: true, record: read.record }
485
- const allowed = CONFLICT_TRANSITIONS[from] ?? []
486
- if (!allowed.includes(status)) {
487
- return {
488
- ok: false,
489
- code: 'invalid_transition',
490
- error: `${from} → ${status} 不被允许(已判定过的记录不许回到 open,要重开请新建一条)`,
654
+ for (const key of PATCHABLE_TEXT) {
655
+ if (patch[key] === undefined) continue
656
+ const limit = CONFLICT_FIELD_LIMITS[key] ?? CONFLICT_FIELD_LIMITS.note
657
+ const { text } = clampText(String(patch[key] ?? ''), limit)
658
+ if (next[key] !== text) {
659
+ next[key] = text
660
+ changed = true
661
+ }
662
+ }
663
+ if (patch.decidedAt !== undefined) {
664
+ // 三态:`''` = 明确清空(撤销用);能解析的字符串 = 写入;**坏值 = 忽略**(保留原值)。
665
+ // 坏值若一律归空,一次带顺手补丁的调用就会把已裁决记录的时间收据清掉 —— 那不是归一,是丢证据。
666
+ const raw = String(patch.decidedAt ?? '')
667
+ if (raw === '') {
668
+ if (next.decidedAt !== '') {
669
+ next.decidedAt = ''
670
+ changed = true
671
+ }
672
+ } else {
673
+ const at = normalizeAt(raw)
674
+ if (at && next.decidedAt !== at) {
675
+ next.decidedAt = at
676
+ changed = true
677
+ }
491
678
  }
492
679
  }
493
680
 
494
- const next = { ...read.record, status }
681
+ // 幂等:同状态重复点不重写文件(界面上连点两下不该既报错又多一次 mtime)。
682
+ if (!changed) return { ok: true, record: cur }
683
+
495
684
  const written = await writeConflict(next, opts)
496
685
  if (!written.ok) return { ok: false, code: written.code, error: written.error }
497
686
  return { ok: true, record: next }
498
687
  }
499
688
 
689
+ /**
690
+ * 改状态并落盘。
691
+ *
692
+ * 状态机见 `CONFLICT_TRANSITIONS`:`open` 只能去 `resolved` / `dismissed`,那两个之间可以互转,
693
+ * **都不许回到 `open`**(要重开就新建一条)。同状态重复点是**幂等**的 —— 界面上连点两下
694
+ * 不该报错,也不重写文件。
695
+ *
696
+ * @returns {Promise<{ok:true, record:object} | {ok:false, code:'invalid'|'invalid_transition'|'missing'|'malformed'|'io_error', error?}>}
697
+ */
698
+ export async function resolveConflict(id, status, opts) {
699
+ return patchConflict(id, { status }, opts)
700
+ }
701
+
500
702
  /**
501
703
  * 删一条(清理)。
502
704
  *
@@ -516,5 +718,6 @@ export async function removeConflict(id, opts) {
516
718
  if (err?.code === 'ENOENT') return { ok: false, code: 'missing', path, error: '冲突记录不存在' }
517
719
  return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
518
720
  }
721
+ invalidateConflictIndex(opts && typeof opts === 'object' ? opts.stateRoot : undefined)
519
722
  return { ok: true, path }
520
723
  }
@@ -41,7 +41,14 @@ export function parseFrontmatter(content) {
41
41
  // 「baseline 走 parseBaseline 那条带 \r?\n 的正则照旧命中,但这条记忆永远检索不到」,
42
42
  // 症状极具欺骗性。下文定义的 parseBaseline 与 extractTitle 早已是 \r?\n。
43
43
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
44
- if (!m) return { name: '', description: '', type: '', tags: [], aliases: [], updated: '', baselinePriority: 0 }
44
+ if (!m) {
45
+ return {
46
+ name: '', description: '', type: '', tags: [], aliases: [], updated: '', baselinePriority: 0,
47
+ // 无 frontmatter = 三字段缺失。R1 要求「缺失即老行为」,所以这里必须是空串而不是 undefined:
48
+ // 下游 `f.invalidAt === ''` 与 `!f.invalidAt` 两种写法都得安全。
49
+ validAt: '', invalidAt: '', supersededBy: '',
50
+ }
51
+ }
45
52
  const body = m[1]
46
53
  const grab = (key) => {
47
54
  // [ \t] / [ \t\r] 而不是 \s:\s 会连 CRLF 的 \r 一起吃进捕获组,值尾留一个 \r
@@ -71,6 +78,20 @@ export function parseFrontmatter(content) {
71
78
  aliases: parseAliases(body),
72
79
  updated,
73
80
  baselinePriority: parseBaselinePriority(body),
81
+ /**
82
+ * 有效性三字段(2.0.0):顶层、可选、缺失一律空串。
83
+ *
84
+ * 刻意**不**回落 metadata 嵌套(不同于上面的 updated):规范只认顶层
85
+ * (`docs/2.0.0-plan.md` §4.1),「换个位置也认」等于让同一份文件的语义由写它的人
86
+ * 用了哪种格式决定 —— 而这三个字段直接决定一条记忆会不会被注入。
87
+ *
88
+ * 值能不能解析**不在这里判**:坏格式也原样返回(R2:认不出就不猜),判定
89
+ * 统一在 `validity.js` —— 解析判据只有一份(D22),这里再加一层「看起来像时间」
90
+ * 的判断,就会变成两份会各自漂移的规则。
91
+ */
92
+ validAt: grab('valid_at'),
93
+ invalidAt: grab('invalid_at'),
94
+ supersededBy: grab('superseded_by'),
74
95
  }
75
96
  }
76
97
 
@@ -455,6 +455,18 @@ async function fullScanMemoryDir(ctx, sessionId, dir) {
455
455
  baselinePriority: meta.baselinePriority || 0,
456
456
  // 新鲜度的两个来源:frontmatter 的 updated(权威)与文件 mtime(退路)。
457
457
  updated: meta.updated || '',
458
+ /**
459
+ * 有效性三字段(2.0.0):与 description / aliases 同一处带出 —— 记录形状只在这里
460
+ * 组装一次,注入链 / 面板 / 审计都从这份记录读。平铺而不是嵌套,是为了让
461
+ * `validity.js` 的判据只依赖 `f.invalidAt` 一个路径(同一形状少一层解构)。
462
+ *
463
+ * 缺失一律空串(R1):三字段全缺时这条记录与 1.9.2 逐字相同 —— 老记忆的
464
+ * 「仍有效、时间未知」不是靠下游特判,而是靠这里没有多出任何真值。
465
+ * 坏格式也原样带出,不在这里判(R2);判据只有 validity.js 一份。
466
+ */
467
+ validAt: meta.validAt || '',
468
+ invalidAt: meta.invalidAt || '',
469
+ supersededBy: meta.supersededBy || '',
458
470
  mtimeMs: gate.mtimeMs,
459
471
  }
460
472
  } catch (err) {
@@ -172,11 +172,38 @@ function assemble(header, action, buildBest) {
172
172
  return [header, best, action].filter(l => l && kept.includes(l)).join('\n')
173
173
  }
174
174
 
175
+ /**
176
+ * 「有矛盾待裁决」那句话(2.0.0)。
177
+ *
178
+ * 为什么必须是**固定一句 + 具体条数**:卡片按内容签名去重(`lib/recall/compose.js`),
179
+ * 签名里不带条数的话,第二封(条数变了)发不出去;而条数不变时重复发同一句就是刷屏。
180
+ * 为什么必须发:抑制是「记忆不再进正文」——用户不看面板时,那是唯一会自己撞进视线的提示
181
+ * (D27 拒绝先上抑制的理由正是「用户无从得知」)。
182
+ */
183
+ function conflictNotice(pending) {
184
+ return `sage-mem:另有 ${countText(pending)} 条矛盾待裁决,相关记忆本轮未注入正文。`
185
+ }
186
+
187
+ /**
188
+ * 把冲突提示并进卡片,且**保住 512 字节预算**。
189
+ *
190
+ * 冲突提示是这一版新增的「必须可见」项,所以它优先于「最佳线索」:放不下时先丢线索,
191
+ * 再放不下就只发这一句 —— 一张只剩提示的卡仍然成立,一张把提示挤掉的卡不成立。
192
+ */
193
+ function withConflictNotice(card, notice) {
194
+ if (!notice) return card
195
+ if (!card) return notice
196
+ const joined = `${card}\n${notice}`
197
+ if (byteLen(joined) <= STATUS_MAX_BYTES) return joined
198
+ return notice
199
+ }
200
+
175
201
  /**
176
202
  * 渲染缺口状态卡。返回 `''` = 本次不该注入任何提示。
177
203
  *
178
204
  * 三条判定(顺序即优先级):
179
205
  * 1. 没有缺口(unread=0 且 omitted=0)且注入了正文 → `''`,没有缺口就不要说话;
206
+ * 但**有矛盾待裁决时例外**:抑制必须可见,那是一句独立的提示;
180
207
  * 2. 没有缺口、也没有注入 → 摘要未命中那一句 + 建议;
181
208
  * 3. 有缺口(有候选没读,或有候选被预算省略)→ 范围 + 条数 + 最佳线索 + 一个动作。
182
209
  *
@@ -184,7 +211,7 @@ function assemble(header, action, buildBest) {
184
211
  * 说「摘要里没有命中」是**谎报故障类型** —— 而这张卡的整个意义就是把
185
212
  * 「没找到」与「找到了但没展示」分开,所以有省略时一律走第 3 条。
186
213
  *
187
- * @param {object} input `{scope, scanned, injected, omitted, unread, best, needle, toolsAvailable, memoryDir}`
214
+ * @param {object} input `{scope, scanned, injected, omitted, unread, best, needle, toolsAvailable, memoryDir, pendingConflicts}`
188
215
  * @returns {string} 卡片文本,或 `''`
189
216
  */
190
217
  export function renderStatusCard(input = {}) {
@@ -199,16 +226,21 @@ export function renderStatusCard(input = {}) {
199
226
  const needle = clipChars(str(i.needle), CLIP.needle)
200
227
  const memoryDir = clipChars(str(i.memoryDir), CLIP.dir)
201
228
  const best = normalizeBest(i.best)
229
+ const pending = count(i.pendingConflicts)
230
+ const notice = pending > 0 ? conflictNotice(pending) : ''
202
231
 
203
232
  const gap = unread > 0 || omitted > 0
204
- if (!gap && injected > 0) return ''
233
+ if (!gap && injected > 0) return notice
205
234
 
206
235
  if (!gap) {
207
- return assemble(missHeader(scope, scanned), missAction({ tools, needle, memoryDir }), () => '')
236
+ return withConflictNotice(assemble(missHeader(scope, scanned), missAction({ tools, needle, memoryDir }), () => ''), notice)
208
237
  }
209
- return assemble(
210
- gapHeader(scope, scanned, injected, unread, omitted),
211
- gapAction({ tools, needle, memoryDir, best }),
212
- room => bestLine(best, room),
238
+ return withConflictNotice(
239
+ assemble(
240
+ gapHeader(scope, scanned, injected, unread, omitted),
241
+ gapAction({ tools, needle, memoryDir, best }),
242
+ room => bestLine(best, room),
243
+ ),
244
+ notice,
213
245
  )
214
246
  }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * sage-mem 记忆层 —— 有效性(`valid_at` / `invalid_at` / `superseded_by`)的解析与判定。
3
+ *
4
+ * 2.0.0 的数据模型层(`docs/2.0.0-plan.md` §4.1 R1–R6)。这一片**只判定、不读盘、不写盘、
5
+ * 不改注入**:它把「frontmatter 里那三个字段」翻译成「这条记忆现在算不算失效」与
6
+ * 「哪些文件该被抑制」两个集合,交给上层(注入链 / 面板)消费。
7
+ *
8
+ * 三条贯穿全文件的规矩:
9
+ *
10
+ * 1. **时钟必须由调用方传入(R3)。** 这些函数一个都不读 `Date.now()`:判定的边界值
11
+ * (`invalid_at` 恰好等于此刻、未来一秒)是夹具与 `TZ=UTC` 的 CI 唯一能钉住的东西,
12
+ * 模块里读一次时钟就变成「跑测试那一刻碰运气」。注入链本来就带着 `nowMs`
13
+ * (`lib/recall/injector.js`),顺着传下来零成本。
14
+ * 2. **认不出就不猜(R2)。** 坏格式一律按「未标注」处理,绝不编一个时间:猜出来的时间
15
+ * 会让一条写坏的标注变成一条**永久失效**的记忆,而失效是静默的 —— 它只表现为
16
+ * 「这条记忆不再出现」,没有报错。
17
+ * 3. **缺失即老行为(R1)。** 三字段全缺时,这里的每个判定都与 1.9.2 的空集逐字相同:
18
+ * 失效集合为空、冲突集合为空。零迁移的全部依据就是这一条。
19
+ *
20
+ * 冲突记录**不是事实来源**(§4.4):一条记忆有 `invalid_at` 才算失效;冲突记录只说明
21
+ * 「有一对手工/模型标记的互斥说法待裁决」。两者不一致时,注入看 frontmatter、面板看记录。
22
+ */
23
+
24
+ /**
25
+ * 被认可的时间格式:ISO 8601 带偏移 —— `YYYY-MM-DDTHH:MM(:SS)?(Z|±HH:MM)`。
26
+ *
27
+ * 为什么**额外接受 1–3 位小数秒**(规格里没写):裁决入口写 `invalid_at` 时会用
28
+ * `new Date().toISOString()`,它的天然产物是 `2026-10-24T18:30:00.000Z`。严格按规格
29
+ * 拒掉小数秒,就等于**宿主自己写的时间自己认不出** → 按未标注处理 → 失效标记静默不生效。
30
+ * 这是最容易发生、也最难发现的一类坑:文件里明明写着 `invalid_at`,那条记忆却照常注入。
31
+ * 小数秒仍是合法 ISO 8601,收它不违反 R2(R2 防的是「像时间的胡话」)。
32
+ *
33
+ * 其余部分刻意从严:必须带时区偏移、必须有 `T`、必须是完整日期。`Date.parse` 其实也认
34
+ * `2026-10-24`(按本地时区解释)与 `Oct 24 2026`,但「哪一天」在跨时区时会漂一天,
35
+ * 而失效判定恰好是「早一天 / 晚一天」的语义 —— 认它等于把一个时区隐患埋进数据模型。
36
+ */
37
+ const TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/
38
+
39
+ /**
40
+ * 解析一个有效性时间值。
41
+ *
42
+ * 两道闸门(R2):格式正则 + `Date.parse` 有限。前者挡「不像 ISO 的胡话」,
43
+ * 后者挡「格式像、日期不存在」(例如月份越界)—— 任一不过都返回 `ok: false`,
44
+ * 由调用方按「未标注」处理,**不要**在这里回落成某个时间。
45
+ *
46
+ * @param {unknown} value — frontmatter 里的原始值(正常是字符串;缺失 / 非字符串都按未标注)
47
+ * @returns {{ok: boolean, ms: number|null}} `ok` 为 false 时 `ms` 必为 null
48
+ */
49
+ export function parseValidityTime(value) {
50
+ if (typeof value !== 'string') return { ok: false, ms: null }
51
+ const raw = value.trim()
52
+ if (!TIME_RE.test(raw)) return { ok: false, ms: null }
53
+ const ms = Date.parse(raw)
54
+ return Number.isFinite(ms) ? { ok: true, ms } : { ok: false, ms: null }
55
+ }
56
+
57
+ /**
58
+ * 这条记忆的失效时刻到了没有。
59
+ *
60
+ * 只读 `file.invalidAt`(**不看** `validAt`:`valid_at` 是「说法何时开始成立」,
61
+ * 与「现在还算不算数」无关。用 `valid_at` 推断失效等于把「开始」当成「结束的线索」,
62
+ * R4 明确禁止这种互相推导)。
63
+ *
64
+ * 四种「不失效」情形都是刻意的:字段缺失 / 坏格式 / 未来时间 / `nowMs` 传了非法值。
65
+ * 后两种宁可当有效:把一条本该注入的记忆藏起来是用户看得见的损失,而把一条刚过期的
66
+ * 多看一轮,下一次注入就会修正。
67
+ *
68
+ * @param {{invalidAt?: string}|null|undefined} file — scan 记录(`file` 字段为文件名)
69
+ * @param {number} nowMs — 调用方的「现在」(毫秒时间戳),不读时钟是 R3 的硬要求
70
+ * @returns {boolean}
71
+ */
72
+ export function isExpired(file, nowMs) {
73
+ if (!Number.isFinite(nowMs)) return false
74
+ const parsed = parseValidityTime(file?.invalidAt)
75
+ // `<=` 而不是 `<`:到点即失效(裁决入口写的就是「从这一刻起不作数」)。
76
+ return parsed.ok && parsed.ms <= nowMs
77
+ }
78
+
79
+ /**
80
+ * 给面板 / 审计 / 状态卡用的一句话视图。
81
+ *
82
+ * `expired` = 已到点失效;`pendingFuture` = 时间可解析但在未来(「暂不失效」,
83
+ * 审计要提示、注入不能动手);两者都为 false 包含「未标注」与「坏格式」两种 ——
84
+ * 它们在行为上等价(老行为、有效),区别只在审计提示里,不要在这里合成第三种状态。
85
+ *
86
+ * 返回值里的时间字段是**原样字符串**(不是时间戳):面板要显示用户写的那个时区,
87
+ * 换算成 UTC 反而让「我写的是下午六点半」变得认不出来。判定另行计算。
88
+ *
89
+ * @param {{invalidAt?: string, supersededBy?: string}|null|undefined} file
90
+ * @param {number} nowMs
91
+ * @returns {{invalidAt: string, supersededBy: string, expired: boolean, pendingFuture: boolean}}
92
+ */
93
+ export function describeValidity(file, nowMs) {
94
+ const invalidAt = typeof file?.invalidAt === 'string' ? file.invalidAt : ''
95
+ const supersededBy = typeof file?.supersededBy === 'string' ? file.supersededBy : ''
96
+ const parsed = parseValidityTime(invalidAt)
97
+ const usable = parsed.ok && Number.isFinite(nowMs)
98
+ return {
99
+ invalidAt,
100
+ supersededBy,
101
+ expired: usable && parsed.ms <= nowMs,
102
+ pendingFuture: usable && parsed.ms > nowMs,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 从一次扫描结果里取出「已失效」的文件名集合。
108
+ *
109
+ * 返回的是**文件名**(scan 记录的 `file` 字段,如 `project_a.md`),不是路径:
110
+ * 注入链与状态卡都按文件名对账,混进绝对路径会让集合在两侧永远对不上
111
+ * (而且绝对路径会把用户目录结构带进日志 / 状态卡)。
112
+ *
113
+ * 纯函数:给同样的 `files` 与 `nowMs` 永远得到同样的集合 —— 失效名单是要进
114
+ * golden 断言与评测基线的,任何隐式输入(时钟 / 环境变量)都会让它不可重放。
115
+ *
116
+ * @param {Array<{file?: string, invalidAt?: string}>} files — `scanMemoryFiles` 的记录
117
+ * @param {number} nowMs
118
+ * @returns {Set<string>}
119
+ */
120
+ export function buildExpirySet(files, nowMs) {
121
+ const out = new Set()
122
+ if (!Array.isArray(files)) return out
123
+ for (const f of files) {
124
+ if (!f || typeof f.file !== 'string' || f.file === '') continue
125
+ if (isExpired(f, nowMs)) out.add(f.file)
126
+ }
127
+ return out
128
+ }
129
+
130
+ /**
131
+ * 从冲突记录里取出「未裁决矛盾」涉及的文件名集合(两方都要)。
132
+ *
133
+ * 只收 `status === 'open'` 且 `kind === 'contradiction'` 的记录(§6.3):
134
+ * 另外三种 kind 都是「想写、没写成」——`stale_version` 时盘上那份**才是现行的**,
135
+ * 把它们也当成「不可信」会重演「记忆悄悄消失」的事故(D27)。「记录还没裁决」
136
+ * 与「记忆不可信」是两件事,这条判据就是那个分界线。
137
+ *
138
+ * `file` 是拟标失效的一方,`otherFile` 是另一方:抑制必须两边都放,只藏一方等于
139
+ * 让用户继续看到一份**已经被判定为可能作废**的说法。`otherFile` 可能缺失 / 为空串
140
+ * (老记录没有这个字段),跳过而不是拼出一个空文件名。
141
+ *
142
+ * @param {Array<{kind?: string, status?: string, file?: string, otherFile?: string}>} records
143
+ * @returns {Set<string>}
144
+ */
145
+ export function buildContradictionFileSet(records) {
146
+ const out = new Set()
147
+ if (!Array.isArray(records)) return out
148
+ for (const r of records) {
149
+ if (!r || r.status !== 'open' || r.kind !== 'contradiction') continue
150
+ for (const key of ['file', 'otherFile']) {
151
+ const name = typeof r[key] === 'string' ? r[key].trim() : ''
152
+ if (name !== '') out.add(name)
153
+ }
154
+ }
155
+ return out
156
+ }