recallnest 3.0.0 → 4.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.
Files changed (246) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +251 -258
  3. package/README_CN.md +226 -247
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +12 -3
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +459 -71
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer-stable.ts +5 -1
  28. package/src/context-composer.ts +11 -2
  29. package/src/distill-lock.ts +246 -74
  30. package/src/dream-pipeline.ts +3 -1
  31. package/src/embedder.ts +24 -0
  32. package/src/env-config.ts +136 -0
  33. package/src/flock.ts +97 -0
  34. package/src/forget-engine.ts +342 -58
  35. package/src/gateway-server.ts +4 -0
  36. package/src/ingest-plan.ts +56 -0
  37. package/src/ingest.ts +294 -121
  38. package/src/kg-contradiction.ts +753 -0
  39. package/src/kg-extractor.ts +17 -0
  40. package/src/kg-store.ts +1 -1
  41. package/src/known-item-eval.ts +455 -0
  42. package/src/llm-client.ts +13 -26
  43. package/src/llm-consolidation.ts +2 -2
  44. package/src/mcp-tool-deps.ts +6 -0
  45. package/src/mcp-tools-advanced.ts +80 -45
  46. package/src/mcp-tools-core.ts +23 -17
  47. package/src/mcp-tools-governance.ts +13 -7
  48. package/src/memory-assets.ts +144 -8
  49. package/src/memory-boundaries.ts +19 -0
  50. package/src/memory-evolution.ts +9 -0
  51. package/src/memory-lint.ts +1 -1
  52. package/src/memory-output.ts +311 -14
  53. package/src/memory-reconcile.ts +1437 -0
  54. package/src/memory-schema.ts +11 -0
  55. package/src/memory-store-port.ts +1 -0
  56. package/src/minis-archive.ts +151 -0
  57. package/src/noise-filter.ts +15 -2
  58. package/src/pii-detector.ts +31 -1
  59. package/src/pivot-apply.ts +55 -5
  60. package/src/pivot-evidence.ts +128 -0
  61. package/src/ppr-traversal.ts +25 -1
  62. package/src/preference-matcher.ts +40 -28
  63. package/src/prospective-memory.ts +7 -4
  64. package/src/retriever.ts +297 -57
  65. package/src/retroactive-boost.ts +3 -2
  66. package/src/runtime-config.ts +15 -1
  67. package/src/scope-policy.ts +1 -0
  68. package/src/session-distiller.ts +6 -2
  69. package/src/session-engine.ts +11 -4
  70. package/src/session-output.ts +20 -1
  71. package/src/session-schema.ts +5 -2
  72. package/src/session-store.ts +59 -12
  73. package/src/skill-promotion.ts +67 -1
  74. package/src/store-receipts.ts +109 -0
  75. package/src/store-write-budget.ts +37 -0
  76. package/src/store.ts +298 -57
  77. package/src/text-fingerprint.ts +25 -0
  78. package/src/trigger-store.ts +443 -0
  79. package/src/ui-server.ts +22 -21
  80. package/src/version-manager.ts +19 -5
  81. package/src/write-verifier.ts +16 -11
  82. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  83. package/src/__tests__/activity-counter.test.ts +0 -125
  84. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  85. package/src/__tests__/admission-control.test.ts +0 -206
  86. package/src/__tests__/age-label.test.ts +0 -33
  87. package/src/__tests__/anchor-generator.test.ts +0 -67
  88. package/src/__tests__/api-server-security.test.ts +0 -102
  89. package/src/__tests__/auto-consolidation.test.ts +0 -168
  90. package/src/__tests__/auto-recall.test.ts +0 -131
  91. package/src/__tests__/babel-integration.test.ts +0 -144
  92. package/src/__tests__/batch-dedup.test.ts +0 -65
  93. package/src/__tests__/belief-history.test.ts +0 -220
  94. package/src/__tests__/canary-eval.test.ts +0 -114
  95. package/src/__tests__/capture-engine.test.ts +0 -1328
  96. package/src/__tests__/capture-heuristic.test.ts +0 -171
  97. package/src/__tests__/cascade-forget.test.ts +0 -210
  98. package/src/__tests__/category-min-scores.test.ts +0 -116
  99. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  100. package/src/__tests__/circuit-breaker.test.ts +0 -99
  101. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  102. package/src/__tests__/confidence-metadata.test.ts +0 -125
  103. package/src/__tests__/confidence-tracker.test.ts +0 -261
  104. package/src/__tests__/conflict-advisor.test.ts +0 -206
  105. package/src/__tests__/conflict-escalation.test.ts +0 -149
  106. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  107. package/src/__tests__/conflict-output.test.ts +0 -85
  108. package/src/__tests__/conflict-store.test.ts +0 -418
  109. package/src/__tests__/connector-v1.test.ts +0 -181
  110. package/src/__tests__/consolidation-engine.test.ts +0 -520
  111. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  112. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  113. package/src/__tests__/context-composer-pins.test.ts +0 -64
  114. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  115. package/src/__tests__/context-reconstructor.test.ts +0 -65
  116. package/src/__tests__/context-renderer.test.ts +0 -91
  117. package/src/__tests__/continuity-eval.test.ts +0 -275
  118. package/src/__tests__/continuity-schema.test.ts +0 -97
  119. package/src/__tests__/contracts.test.ts +0 -262
  120. package/src/__tests__/conversation-importer.test.ts +0 -692
  121. package/src/__tests__/core-recall-eval.test.ts +0 -235
  122. package/src/__tests__/core-summary.test.ts +0 -109
  123. package/src/__tests__/data-checkup.test.ts +0 -304
  124. package/src/__tests__/decay-exemption.test.ts +0 -129
  125. package/src/__tests__/dedup-actions.test.ts +0 -58
  126. package/src/__tests__/dedup-guard.test.ts +0 -39
  127. package/src/__tests__/deterministic-id.test.ts +0 -39
  128. package/src/__tests__/distill-lock.test.ts +0 -305
  129. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  130. package/src/__tests__/doctor.test.ts +0 -319
  131. package/src/__tests__/dream-pipeline.test.ts +0 -820
  132. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  133. package/src/__tests__/dream-write-batching.test.ts +0 -211
  134. package/src/__tests__/drill-down.test.ts +0 -108
  135. package/src/__tests__/embedder.test.ts +0 -122
  136. package/src/__tests__/emotion-decay.test.ts +0 -46
  137. package/src/__tests__/emotion-detector.test.ts +0 -56
  138. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  139. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  140. package/src/__tests__/entity-resolver.test.ts +0 -58
  141. package/src/__tests__/env-config-invariant.test.ts +0 -80
  142. package/src/__tests__/env-config.test.ts +0 -211
  143. package/src/__tests__/error-signature-boost.test.ts +0 -87
  144. package/src/__tests__/error-signature.test.ts +0 -95
  145. package/src/__tests__/error-taxonomy.test.ts +0 -129
  146. package/src/__tests__/ethics-layer.test.ts +0 -476
  147. package/src/__tests__/event-segmenter.test.ts +0 -153
  148. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  149. package/src/__tests__/frequency-tracker.test.ts +0 -113
  150. package/src/__tests__/freshness.test.ts +0 -186
  151. package/src/__tests__/graph-export.test.ts +0 -489
  152. package/src/__tests__/hotness-blend.test.ts +0 -283
  153. package/src/__tests__/ingest-chunking.test.ts +0 -44
  154. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  155. package/src/__tests__/ingest.test.ts +0 -360
  156. package/src/__tests__/interference-detector.test.ts +0 -203
  157. package/src/__tests__/kg-extractor.test.ts +0 -192
  158. package/src/__tests__/kg-store.test.ts +0 -300
  159. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  160. package/src/__tests__/llm-consolidation.test.ts +0 -226
  161. package/src/__tests__/maintenance-locks.test.ts +0 -197
  162. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  163. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  164. package/src/__tests__/memory-boundaries.test.ts +0 -282
  165. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  166. package/src/__tests__/memory-evolution.test.ts +0 -271
  167. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  168. package/src/__tests__/memory-lint.test.ts +0 -421
  169. package/src/__tests__/memory-output.test.ts +0 -224
  170. package/src/__tests__/memory-promotion.test.ts +0 -315
  171. package/src/__tests__/memory-utility.test.ts +0 -256
  172. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  173. package/src/__tests__/multi-vector.test.ts +0 -242
  174. package/src/__tests__/narrative-architecture.test.ts +0 -456
  175. package/src/__tests__/noise-filter.test.ts +0 -205
  176. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  177. package/src/__tests__/obsidian-connector.test.ts +0 -363
  178. package/src/__tests__/omitted-reporting.test.ts +0 -98
  179. package/src/__tests__/openai-contract.test.ts +0 -513
  180. package/src/__tests__/pending-review.test.ts +0 -77
  181. package/src/__tests__/pii-redact.test.ts +0 -333
  182. package/src/__tests__/pivot-apply.test.ts +0 -373
  183. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  184. package/src/__tests__/pivot-distill.test.ts +0 -1178
  185. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  186. package/src/__tests__/ppr-traversal.test.ts +0 -256
  187. package/src/__tests__/prediction-engine.test.ts +0 -349
  188. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  189. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  190. package/src/__tests__/preference-matcher.test.ts +0 -282
  191. package/src/__tests__/preference-slots.test.ts +0 -205
  192. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  193. package/src/__tests__/prospective-memory.test.ts +0 -283
  194. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  195. package/src/__tests__/query-entity-detector.test.ts +0 -171
  196. package/src/__tests__/query-expander-alias.test.ts +0 -146
  197. package/src/__tests__/query-expander.test.ts +0 -34
  198. package/src/__tests__/recall-gate.test.ts +0 -266
  199. package/src/__tests__/recall-governor.test.ts +0 -227
  200. package/src/__tests__/recall-mode.test.ts +0 -64
  201. package/src/__tests__/resolve-db-path.test.ts +0 -130
  202. package/src/__tests__/resolve-tier.test.ts +0 -64
  203. package/src/__tests__/result-synthesizer.test.ts +0 -198
  204. package/src/__tests__/resume-context.test.ts +0 -4898
  205. package/src/__tests__/retrieval-eval.test.ts +0 -163
  206. package/src/__tests__/retrieval-trace.test.ts +0 -213
  207. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  208. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  209. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  210. package/src/__tests__/retroactive-boost.test.ts +0 -223
  211. package/src/__tests__/salience-scoring.test.ts +0 -106
  212. package/src/__tests__/scope-inventory.test.ts +0 -200
  213. package/src/__tests__/scope-policy.test.ts +0 -69
  214. package/src/__tests__/scope-suggester.test.ts +0 -80
  215. package/src/__tests__/session-checkpoint.test.ts +0 -291
  216. package/src/__tests__/session-distiller.test.ts +0 -640
  217. package/src/__tests__/session-gc.test.ts +0 -215
  218. package/src/__tests__/skill-engine.test.ts +0 -552
  219. package/src/__tests__/skill-promotion.test.ts +0 -654
  220. package/src/__tests__/skill-verifier.test.ts +0 -122
  221. package/src/__tests__/source-heartbeat.test.ts +0 -254
  222. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  223. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  224. package/src/__tests__/store-read-consistency.test.ts +0 -122
  225. package/src/__tests__/store-schema-migration.test.ts +0 -87
  226. package/src/__tests__/store-scope-audit.test.ts +0 -81
  227. package/src/__tests__/store-upsert.test.ts +0 -201
  228. package/src/__tests__/supersede-chain.test.ts +0 -154
  229. package/src/__tests__/synthesis-contract.test.ts +0 -252
  230. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  231. package/src/__tests__/temporal-parser.test.ts +0 -241
  232. package/src/__tests__/temporal-validity.test.ts +0 -160
  233. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  234. package/src/__tests__/term-registry.test.ts +0 -121
  235. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  236. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  237. package/src/__tests__/topic-tag.test.ts +0 -113
  238. package/src/__tests__/trusted-memory.test.ts +0 -867
  239. package/src/__tests__/ui-server-security.test.ts +0 -93
  240. package/src/__tests__/usage-tracker.test.ts +0 -287
  241. package/src/__tests__/utility-weight.test.ts +0 -57
  242. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  243. package/src/__tests__/version-manager.test.ts +0 -248
  244. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  245. package/src/__tests__/workflow-observation.test.ts +0 -433
  246. package/src/__tests__/write-verifier.test.ts +0 -122
package/src/store.ts CHANGED
@@ -12,6 +12,7 @@ import type { DurableMemoryCategory } from "./memory-schema.js";
12
12
  import { matchesScopeFilter, type ScopeMatchMode } from "./scope-policy.js";
13
13
  import { detectEmotionIfEnabled } from "./emotion-detector.js";
14
14
  import { withWriteLock } from "./distill-lock.js";
15
+ import { STORE_WRITE_LOCK_OPTS, STORE_WRITE_LONG_LOCK_OPTS } from "./store-write-budget.js";
15
16
  import { incrementWriteCount } from "./activity-counter.js";
16
17
  import type { MemoryStorePort, MemoryStoreStats, MemoryStoreUpdate } from "./memory-store-port.js";
17
18
  import { clearVersionGroupMetadata, planSingletonVersionGroupRepairs } from "./version-manager.js";
@@ -57,6 +58,19 @@ export interface MemorySearchResult {
57
58
  score: number;
58
59
  }
59
60
 
61
+ /** replaceWithArchivedVersion 的 plan 在锁内给出的:要新写的历史行,以及对原行的改动 */
62
+ export interface ArchivedReplacePlan {
63
+ history: MemoryEntry;
64
+ updates: MemoryStoreUpdate;
65
+ }
66
+
67
+ export type ArchivedReplaceResult =
68
+ /** 原行已不在(重读时没找到,或写回时已被删掉):库里什么都没留下 */
69
+ | { status: "missing" }
70
+ /** plan 返回 null:什么都没写,current 是锁内读到的行 */
71
+ | { status: "unchanged"; current: MemoryEntry }
72
+ | { status: "replaced"; entry: MemoryEntry; history: MemoryEntry };
73
+
60
74
  export interface StoreConfig {
61
75
  dbPath: string;
62
76
  vectorDim: number;
@@ -86,6 +100,14 @@ export interface LegacyScopeAudit {
86
100
  samples: LegacyScopeAuditSample[];
87
101
  }
88
102
 
103
+ // ============================================================================
104
+ // store-write lock options
105
+ // ============================================================================
106
+
107
+ // The store-write lock options live in store-write-budget.ts (the HTTP servers size their timeouts
108
+ // from them too); re-exported here for existing callers.
109
+ export { STORE_WRITE_LOCK_OPTS, STORE_WRITE_LONG_LOCK_OPTS };
110
+
89
111
  // ============================================================================
90
112
  // Deterministic ID
91
113
  // ============================================================================
@@ -133,7 +155,7 @@ function clampInt(value: number, min: number, max: number): number {
133
155
  return Math.min(max, Math.max(min, Math.floor(value)));
134
156
  }
135
157
 
136
- function escapeSqlLiteral(value: string): string {
158
+ export function escapeSqlLiteral(value: string): string {
137
159
  return value.replace(/'/g, "''");
138
160
  }
139
161
 
@@ -232,7 +254,12 @@ const TABLE_NAME = "memories";
232
254
  * 应用层双检 `matchesScopeFilter(row, filter, mode)` 必须传同一个 mode,否则 SQL 收紧了
233
255
  * 应用层又放行(或反之),两层判据打架。
234
256
  */
235
- function scopeWhereClause(scopeFilter: string[], mode: ScopeMatchMode = "family"): string {
257
+ /** `scope NOT IN (...)` — exact scope names, never prefix families. */
258
+ export function scopeExclusionClause(excludeScopes: string[]): string {
259
+ return `(scope NOT IN (${excludeScopes.map((scope) => `'${escapeSqlLiteral(scope)}'`).join(", ")}))`;
260
+ }
261
+
262
+ export function scopeWhereClause(scopeFilter: string[], mode: ScopeMatchMode = "family"): string {
236
263
  return scopeFilter
237
264
  .map(scope => {
238
265
  const safe = escapeSqlLiteral(scope);
@@ -475,7 +502,7 @@ export class MemoryStore implements MemoryStorePort {
475
502
  // window a crash could tear). toWrite is already in-batch deduped by id above,
476
503
  // so the merge key matches ≤1 existing row (given no pre-existing dup-id rows).
477
504
  await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(toWrite);
478
- }, { expireMs: 30_000 });
505
+ }, STORE_WRITE_LOCK_OPTS);
479
506
  } catch (err: any) {
480
507
  const code = err.code || "";
481
508
  const message = err.message || String(err);
@@ -494,6 +521,75 @@ export class MemoryStore implements MemoryStorePort {
494
521
  return toWrite.length;
495
522
  }
496
523
 
524
+ /**
525
+ * 只插入库里还不存在的 id,已存在的行一律不动(storeBatch 的 whenMatchedUpdateAll 会整行覆盖)。
526
+ *
527
+ * 给记忆文件对账用(memory-reconcile.ts):规划时确认过「这段文字库里没有」,但规划到写入之间
528
+ * 别的写方(MCP / API / dream)可能已写入同一个确定性 id;覆盖会冲掉那一行的 metadata 与 evolution。
529
+ * 写锁内先查已存在的 id,再以 whenNotMatchedInsertAll 写入——即便查询与写入之间有别的进程绕过
530
+ * 写锁插入同 id,mergeInsert 也不会覆盖它。
531
+ */
532
+ async insertIfAbsent(
533
+ entries: (Omit<MemoryEntry, "id" | "timestamp"> & { id?: string })[],
534
+ ): Promise<{ inserted: string[]; skippedExisting: string[] }> {
535
+ if (entries.length === 0) return { inserted: [], skippedExisting: [] };
536
+ await this.ensureInitialized();
537
+
538
+ const byId = new Map<string, MemoryEntry>();
539
+ for (const entry of entries) {
540
+ const full: MemoryEntry = {
541
+ ...entry,
542
+ id: entry.id ?? deterministicId(entry.scope, entry.text),
543
+ timestamp: Date.now(),
544
+ metadata: entry.metadata || "{}",
545
+ language: entry.language || "en",
546
+ fts_text: entry.fts_text || entry.text,
547
+ };
548
+ const emotionResult = detectEmotionIfEnabled(full.text);
549
+ if (emotionResult) {
550
+ const meta = JSON.parse(full.metadata || "{}");
551
+ meta.emotion = emotionResult;
552
+ full.metadata = JSON.stringify(meta);
553
+ }
554
+ byId.set(full.id, full); // 同批同 id 只留最后一条
555
+ }
556
+ const candidates = [...byId.values()];
557
+
558
+ let result: { inserted: string[]; skippedExisting: string[] };
559
+ try {
560
+ result = await withWriteLock("store-write", async () => {
561
+ const conditions = candidates.map((e) => `id = '${escapeSqlLiteral(e.id)}'`).join(" OR ");
562
+ const existing = await this.table!.query().where(conditions).select(["id"]).limit(candidates.length).toArray();
563
+ const existingIds = new Set(existing.map((row) => row.id as string));
564
+ const fresh = candidates.filter((e) => !existingIds.has(e.id));
565
+ if (fresh.length > 0) {
566
+ await this.table!.mergeInsert("id").whenNotMatchedInsertAll().execute(fresh);
567
+ }
568
+ return {
569
+ inserted: fresh.map((e) => e.id),
570
+ skippedExisting: candidates.filter((e) => existingIds.has(e.id)).map((e) => e.id),
571
+ };
572
+ }, STORE_WRITE_LOCK_OPTS);
573
+ } catch (err: any) {
574
+ const code = err.code || "";
575
+ const message = err.message || String(err);
576
+ throw new Error(
577
+ `Failed to insert ${candidates.length} memories in "${this.config.dbPath}": ${code} ${message}` +
578
+ (this.schemaMigrationError ? `\n Schema migration failed at init: ${this.schemaMigrationError}` : "")
579
+ );
580
+ }
581
+ try {
582
+ const statsPath = this.activityStatsPath();
583
+ const countByScope = new Map<string, number>();
584
+ for (const e of candidates) {
585
+ if (!result.inserted.includes(e.id)) continue;
586
+ countByScope.set(e.scope, (countByScope.get(e.scope) ?? 0) + 1);
587
+ }
588
+ for (const [scope, n] of countByScope) await incrementWriteCount(scope, n, { statsPath });
589
+ } catch { /* activity tracking is best-effort */ }
590
+ return result;
591
+ }
592
+
497
593
  /**
498
594
  * P0-1/P0-2/P1: Idempotent, crash-atomic write of a fully-formed entry, serialized
499
595
  * across processes.
@@ -516,7 +612,7 @@ export class MemoryStore implements MemoryStorePort {
516
612
  await this.ensureInitialized();
517
613
  await withWriteLock("store-write", async () => {
518
614
  await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([entry]);
519
- }, { expireMs: 30_000 });
615
+ }, STORE_WRITE_LOCK_OPTS);
520
616
  // Per-scope write count drives dream activation. Best-effort — never block a write.
521
617
  try {
522
618
  await incrementWriteCount(entry.scope, 1, { statsPath: this.activityStatsPath() });
@@ -524,6 +620,38 @@ export class MemoryStore implements MemoryStorePort {
524
620
  return entry;
525
621
  }
526
622
 
623
+ /**
624
+ * Upsert that never moves a row back in time: a row already holding a newer timestamp is left
625
+ * as it is. The check is the merge's own condition, so it holds even when the caller's lock has
626
+ * expired and another writer got in first. Returns whether the row was inserted or updated.
627
+ * Used by the checkpoint mirror (src/checkpoint-mirror.ts), whose timestamp is the checkpoint's.
628
+ */
629
+ async upsertUnlessNewer(entry: MemoryEntry): Promise<boolean> {
630
+ await this.ensureInitialized();
631
+ // Every column present, defaults as in store(): lance 2.0's conditional merge panics on a source
632
+ // batch missing columns ("Incorrect number of arrays for StructArray fields"), which the
633
+ // unconditional merge in upsert() tolerates.
634
+ const full: MemoryEntry = {
635
+ ...entry,
636
+ metadata: entry.metadata || "{}",
637
+ language: entry.language || "en",
638
+ fts_text: entry.fts_text || entry.text,
639
+ };
640
+ const result = await withWriteLock("store-write", async () =>
641
+ this.table!.mergeInsert("id")
642
+ .whenMatchedUpdateAll({ where: "target.timestamp <= source.timestamp" })
643
+ .whenNotMatchedInsertAll()
644
+ .execute([full]),
645
+ STORE_WRITE_LOCK_OPTS);
646
+ const written = (result?.numInsertedRows ?? 0) + (result?.numUpdatedRows ?? 0) > 0;
647
+ if (written) {
648
+ try {
649
+ await incrementWriteCount(entry.scope, 1, { statsPath: this.activityStatsPath() });
650
+ } catch { /* activity tracking is best-effort */ }
651
+ }
652
+ return written;
653
+ }
654
+
527
655
  /**
528
656
  * Import a pre-built entry while preserving its id/timestamp.
529
657
  * Used for re-embedding / migration / A/B testing across embedding models.
@@ -582,6 +710,7 @@ export class MemoryStore implements MemoryStorePort {
582
710
  minScore = 0.3,
583
711
  scopeFilter?: string[],
584
712
  scopeMatch: ScopeMatchMode = "family",
713
+ excludeScopes?: string[],
585
714
  ): Promise<MemorySearchResult[]> {
586
715
  await this.ensureInitialized();
587
716
 
@@ -593,8 +722,13 @@ export class MemoryStore implements MemoryStorePort {
593
722
  // Apply scope filter if provided. Default mode "family" = 历史行为(含冒号精确 /
594
723
  // 无冒号前缀);exact 由调用方显式声明。**必须下推到 SQL,不能事后在应用层滤**——
595
724
  // top-k 在收窄前就被兄弟 scope 占满,滤完只剩残缺候选(2026-08-16 互审 C1)。
596
- if (scopeFilter && scopeFilter.length > 0) {
597
- query = query.where(`(${scopeWhereClause(scopeFilter, scopeMatch)})`);
725
+ // Exclusions are pushed down for the same reason (resolveExcludedScopes in memory-boundaries.ts).
726
+ const where = [
727
+ ...(scopeFilter && scopeFilter.length > 0 ? [`(${scopeWhereClause(scopeFilter, scopeMatch)})`] : []),
728
+ ...(excludeScopes && excludeScopes.length > 0 ? [scopeExclusionClause(excludeScopes)] : []),
729
+ ];
730
+ if (where.length > 0) {
731
+ query = query.where(where.join(" AND "));
598
732
  }
599
733
 
600
734
  const results = await query.toArray();
@@ -612,6 +746,7 @@ export class MemoryStore implements MemoryStorePort {
612
746
  if (!matchesScopeFilter(rowScope, scopeFilter, scopeMatch)) {
613
747
  continue;
614
748
  }
749
+ if (excludeScopes?.includes(rowScope)) continue;
615
750
 
616
751
  mapped.push({
617
752
  entry: {
@@ -633,7 +768,7 @@ export class MemoryStore implements MemoryStorePort {
633
768
  return mapped;
634
769
  }
635
770
 
636
- async bm25Search(query: string, limit = 5, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
771
+ async bm25Search(query: string, limit = 5, scopeFilter?: string[], excludeScopes?: string[]): Promise<MemorySearchResult[]> {
637
772
  await this.ensureInitialized();
638
773
 
639
774
  if (!this.ftsIndexCreated) {
@@ -647,8 +782,12 @@ export class MemoryStore implements MemoryStorePort {
647
782
  let searchQuery = this.table!.search(query, "fts").limit(safeLimit);
648
783
 
649
784
  // Apply scope filter if provided (family mode, same as vectorSearch's default)
650
- if (scopeFilter && scopeFilter.length > 0) {
651
- searchQuery = searchQuery.where(`(${scopeWhereClause(scopeFilter)})`);
785
+ const where = [
786
+ ...(scopeFilter && scopeFilter.length > 0 ? [`(${scopeWhereClause(scopeFilter)})`] : []),
787
+ ...(excludeScopes && excludeScopes.length > 0 ? [scopeExclusionClause(excludeScopes)] : []),
788
+ ];
789
+ if (where.length > 0) {
790
+ searchQuery = searchQuery.where(where.join(" AND "));
652
791
  }
653
792
 
654
793
  const results = await searchQuery.toArray();
@@ -661,6 +800,7 @@ export class MemoryStore implements MemoryStorePort {
661
800
  if (!matchesScopeFilter(rowScope, scopeFilter)) {
662
801
  continue;
663
802
  }
803
+ if (excludeScopes?.includes(rowScope)) continue;
664
804
 
665
805
  // LanceDB FTS _score is raw BM25 (unbounded). Normalize with sigmoid.
666
806
  // LanceDB may return BigInt for numeric columns; coerce safely.
@@ -743,6 +883,7 @@ export class MemoryStore implements MemoryStorePort {
743
883
  limit = 20,
744
884
  offset = 0,
745
885
  scopeMatch: ScopeMatchMode = "family",
886
+ excludeScopes?: string[],
746
887
  ): Promise<MemoryEntry[]> {
747
888
  await this.ensureInitialized();
748
889
 
@@ -751,6 +892,10 @@ export class MemoryStore implements MemoryStorePort {
751
892
  // Build where conditions
752
893
  const conditions: string[] = [];
753
894
 
895
+ if (excludeScopes && excludeScopes.length > 0) {
896
+ conditions.push(scopeExclusionClause(excludeScopes));
897
+ }
898
+
754
899
  if (scopeFilter && scopeFilter.length > 0) {
755
900
  // 收窄必须在 SQL 里:limit 是在这之后下推的,事后过滤等于先截断再筛,
756
901
  // 目标 scope 实际拿到多少行不可控(2026-08-16 互审 C1/K3)。
@@ -805,15 +950,17 @@ export class MemoryStore implements MemoryStorePort {
805
950
  limit?: number;
806
951
  offset?: number;
807
952
  includeVector?: boolean;
953
+ /** 默认 family:无冒号的 scope 按前缀匹配("memory" 会带进 "memory:pivot");exact 只要全等。 */
954
+ scopeMatch?: ScopeMatchMode;
808
955
  } = {}): Promise<MemoryEntry[]> {
809
956
  await this.ensureInitialized();
810
- const { scopeFilter, category, limit = 1000, offset = 0, includeVector = false } = opts;
957
+ const { scopeFilter, category, limit = 1000, offset = 0, includeVector = false, scopeMatch = "family" } = opts;
811
958
 
812
959
  let query = this.table!.query();
813
960
 
814
961
  const conditions: string[] = [];
815
962
  if (scopeFilter && scopeFilter.length > 0) {
816
- conditions.push(`((${scopeWhereClause(scopeFilter)}))`);
963
+ conditions.push(`((${scopeWhereClause(scopeFilter, scopeMatch)}))`);
817
964
  }
818
965
  if (category) {
819
966
  conditions.push(`category = '${escapeSqlLiteral(category)}'`);
@@ -1053,30 +1200,118 @@ export class MemoryStore implements MemoryStorePort {
1053
1200
  throw new Error(`Invalid memory ID format: ${id}`);
1054
1201
  }
1055
1202
 
1056
- let rows: any[];
1203
+ // 前缀先在锁外解析成完整 id(全表扫,不放进锁里拉长持锁时间);读整行与写回都在锁内按完整 id 做。
1204
+ let fullId: string;
1057
1205
  if (isFullId) {
1058
- const safeId = escapeSqlLiteral(id);
1059
- rows = await this.table!.query().where(`id = '${safeId}'`).limit(1).toArray();
1206
+ fullId = id;
1060
1207
  } else {
1061
- // Prefix match
1062
- const all = await this.table!.query().select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata", "language", "fts_text"]).toArray();
1063
- rows = all.filter((r: any) => (r.id as string).startsWith(id));
1064
- if (rows.length > 1) {
1065
- throw new Error(`Ambiguous prefix "${id}" matches ${rows.length} memories. Use a longer prefix or full ID.`);
1208
+ const all = await this.table!.query().select(["id"]).toArray();
1209
+ const matches = all.filter((r) => (r.id as string).startsWith(id));
1210
+ if (matches.length > 1) {
1211
+ throw new Error(`Ambiguous prefix "${id}" matches ${matches.length} memories. Use a longer prefix or full ID.`);
1066
1212
  }
1213
+ if (matches.length === 0) return null;
1214
+ fullId = matches[0].id as string;
1067
1215
  }
1068
1216
 
1069
- if (rows.length === 0) return null;
1217
+ // 写路径一律包在 store-write 锁里(2026-08-14 起;巩固的批量元数据改动现在走 patchMetadataBatch,不再经这里)。
1218
+ // 2026-10-17:读也挪进锁内、写回只更新已存在的行。原先锁外读整行、锁内 upsert,别的进程在这两步之间
1219
+ // 删掉这一行(forget 等,delete() 不拿写锁),会被按读到的旧样子插回来;在这两步之间改的元数据也会被旧读数盖掉。
1220
+ return withWriteLock("store-write", async () => {
1221
+ const rows = await this.table!.query().where(`id = '${escapeSqlLiteral(fullId)}'`).limit(1).toArray();
1222
+ if (rows.length === 0) return null;
1223
+ const row = rows[0] as Record<string, unknown>;
1224
+ if (!matchesScopeFilter((row.scope as string | undefined) ?? "", scopeFilter)) {
1225
+ throw new Error(`Memory ${id} is outside accessible scopes`);
1226
+ }
1227
+ const updated = this.buildUpdatedEntry(row, updates);
1228
+ // 只更新已存在的行:锁内读完之后被不拿锁的 delete() 删掉的行不插回来
1229
+ const res = await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute([updated]);
1230
+ return res.numUpdatedRows > 0 ? updated : null;
1231
+ }, STORE_WRITE_LOCK_OPTS);
1232
+ }
1070
1233
 
1071
- const row = rows[0];
1072
- const rowScope = (row.scope as string | undefined) ?? "";
1234
+ /**
1235
+ * 在同一次 store-write 持锁里:按完整 id 重读原行 → 不在就什么都不写 → 在就交给 plan 拿最新行算出
1236
+ * 历史行与对原行的改动,先写历史行、再只更新原行(2026-10-17,信念归档与覆盖不再是锁外两次写)。
1237
+ *
1238
+ * - plan 在锁内同步执行,只能做纯计算:取向量 / 调 embedder 这类慢操作由调用方在锁外先做好;
1239
+ * plan 里也不能调用会再拿 store-write 的公有方法(锁不可重入,会等自己到超时)。
1240
+ * - plan 返回 null:不归档也不改原行,结果为 unchanged(带上锁内读到的行)。
1241
+ * - 历史行按 plan 给的原样写入(language / fts_text 由 plan 负责,见 belief-history 的 replaceBeliefVersion)。
1242
+ * - 原行在重读时已不在,或在写历史行之后、更新原行之前被不拿锁的 delete() 删掉:撤掉本次写的历史行
1243
+ * (它在本次之前就已存在则不动),结果为 missing,库里不留下指向不存在原行的历史行;撤销本身失败只记日志,仍返回 missing。
1244
+ * - 更新原行抛错时同样撤掉本次写的历史行,再把错误抛出去。
1245
+ *
1246
+ * 已知局限:历史行 id 在本次之前就已存在时,这里照样整行覆盖它,失败时也不还原(undoHistory 只删本次新建的)。
1247
+ * 历史行 id 按内容寻址(原行 id、timestamp、正文),所以只有同一版本先前写到一半(历史行写了、原行没更新)
1248
+ * 之后重试才会撞上,覆盖进去的也是同一版本的内容。
1249
+ */
1250
+ async replaceWithArchivedVersion(
1251
+ fullId: string,
1252
+ plan: (current: MemoryEntry) => ArchivedReplacePlan | null,
1253
+ scopeFilter?: string[],
1254
+ ): Promise<ArchivedReplaceResult> {
1255
+ await this.ensureInitialized();
1073
1256
 
1074
- // Check scope permissions
1075
- if (!matchesScopeFilter(rowScope, scopeFilter)) {
1076
- throw new Error(`Memory ${id} is outside accessible scopes`);
1257
+ const result = await withWriteLock("store-write", async (): Promise<ArchivedReplaceResult> => {
1258
+ const rows = await this.table!.query().where(`id = '${escapeSqlLiteral(fullId)}'`).limit(1).toArray();
1259
+ if (rows.length === 0) return { status: "missing" };
1260
+ const row = rows[0] as Record<string, unknown>;
1261
+ if (!matchesScopeFilter((row.scope as string | undefined) ?? "", scopeFilter)) {
1262
+ throw new Error(`Memory ${fullId} is outside accessible scopes`);
1263
+ }
1264
+ const current = this.buildUpdatedEntry(row, {});
1265
+ const planned = plan(current);
1266
+ if (!planned) return { status: "unchanged", current };
1267
+
1268
+ // 历史行原样写入:language / fts_text 由 plan 按正文算好,这里不再用 "en" / 原文顶替
1269
+ const { history } = planned;
1270
+ if (history.id === fullId) {
1271
+ throw new Error(`History row id collided with canonical id ${fullId}`);
1272
+ }
1273
+ const updated = this.buildUpdatedEntry(row, planned.updates);
1274
+ const safeHistoryId = escapeSqlLiteral(history.id);
1275
+ const historyExisted = (await this.table!.query().select(["id"]).where(`id = '${safeHistoryId}'`).limit(1).toArray()).length > 0;
1276
+ const undoHistory = async (): Promise<void> => {
1277
+ if (!historyExisted) await this.table!.delete(`id = '${safeHistoryId}'`);
1278
+ };
1279
+
1280
+ await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([history]);
1281
+ let numUpdatedRows: number;
1282
+ try {
1283
+ numUpdatedRows = (await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute([updated])).numUpdatedRows;
1284
+ } catch (err) {
1285
+ try {
1286
+ await undoHistory();
1287
+ } catch (undoErr) {
1288
+ console.error(`[replaceWithArchivedVersion] failed to remove history row ${history.id} after the canonical update failed: ${undoErr instanceof Error ? undoErr.message : String(undoErr)}`);
1289
+ }
1290
+ throw err;
1291
+ }
1292
+ if (numUpdatedRows === 0) {
1293
+ try {
1294
+ await undoHistory();
1295
+ } catch (undoErr) {
1296
+ console.error(`[replaceWithArchivedVersion] failed to remove history row ${history.id} after the canonical row vanished: ${undoErr instanceof Error ? undoErr.message : String(undoErr)}`);
1297
+ }
1298
+ return { status: "missing" };
1299
+ }
1300
+ return { status: "replaced", entry: updated, history };
1301
+ }, STORE_WRITE_LOCK_OPTS);
1302
+
1303
+ if (result.status === "replaced") {
1304
+ // 与 upsert() 一致:历史行算一次写入,驱动 dream 激活。尽力而为,不阻塞写入。
1305
+ try {
1306
+ await incrementWriteCount(result.history.scope, 1, { statsPath: this.activityStatsPath() });
1307
+ } catch { /* activity tracking is best-effort */ }
1077
1308
  }
1309
+ return result;
1310
+ }
1078
1311
 
1079
- // Build updated entry, preserving original timestamp and language fields
1312
+ /** 由库里的一行加上改动拼出写回的整行,保留原 timestamp / language(update 与 patchMetadata 共用) */
1313
+ private buildUpdatedEntry(row: Record<string, unknown>, updates: MemoryStoreUpdate): MemoryEntry {
1314
+ const rowScope = (row.scope as string | undefined) ?? "";
1080
1315
  const updated: MemoryEntry = {
1081
1316
  id: row.id as string,
1082
1317
  text: updates.text ?? (row.text as string),
@@ -1102,22 +1337,6 @@ export class MemoryStore implements MemoryStorePort {
1102
1337
  updated.metadata = JSON.stringify(meta);
1103
1338
  }
1104
1339
  }
1105
-
1106
- // Atomic upsert via mergeInsert — replaces the previous delete+add two-step,
1107
- // which could lose the row entirely on a crash between steps and exposed a
1108
- // window where concurrent reads saw the id momentarily vanish.
1109
- //
1110
- // 2026-08-14: 包进 store-write 锁与 upsert/storeBatch 对齐 —— 此前 update 是唯一
1111
- // 裸奔的写路径(dream 的 3a/3b/auto-gc 全走它),与其他进程的 mergeInsert 并发时
1112
- // 靠 LanceDB 乐观重试硬扛,是 commit conflict 的隐性源之一。
1113
- await withWriteLock("store-write", async () => {
1114
- await this.table!
1115
- .mergeInsert("id")
1116
- .whenMatchedUpdateAll()
1117
- .whenNotMatchedInsertAll()
1118
- .execute([updated]);
1119
- }, { expireMs: 30_000 });
1120
-
1121
1340
  return updated;
1122
1341
  }
1123
1342
 
@@ -1134,11 +1353,14 @@ export class MemoryStore implements MemoryStorePort {
1134
1353
  * metadata object. A throwing patchFn abandons that patch — the error
1135
1354
  * propagates to the caller, later queued patches still run.
1136
1355
  *
1137
- * Scope of guarantee: in-process only. Cross-process writers (multiple
1138
- * mcp-server instances) and the remaining direct update() callers
1139
- * (consolidation/capture/forget/prospective/conflict engines, auto-gc)
1140
- * are NOT serialized by this queue. Long-term direction: make this the
1141
- * only legal metadata write path.
1356
+ * Scope of guarantee: the per-id queue orders patches within this process;
1357
+ * since 2026-09-24 the read and the write both happen inside the
1358
+ * cross-process store-write lock, so a commit made by another process
1359
+ * between them (reconcile retirement, dream consolidation, GC archive) is
1360
+ * read and kept instead of overwritten. Direct update() callers that pass a
1361
+ * metadata string they read earlier can still overwrite — they are not
1362
+ * read-modify-write. Long-term direction: make this the only legal metadata
1363
+ * write path.
1142
1364
  */
1143
1365
  async patchMetadata(
1144
1366
  id: string,
@@ -1159,9 +1381,17 @@ export class MemoryStore implements MemoryStorePort {
1159
1381
  fullId = resolved.id;
1160
1382
  }
1161
1383
 
1162
- const run = async (): Promise<MemoryEntry | null> => {
1163
- const entry = await this.getById(fullId);
1164
- if (!entry) return null;
1384
+ // 读与写都在 store-write 锁内(2026-09-24):原先在锁外 getById、再经 update() 写回,别的进程在这两步之间的提交
1385
+ // (对账下架、dream 合并、GC 归档)会被这里拿旧行算出的元数据整行盖掉。锁不可重入,所以锁内不调 update(),
1386
+ // 直接按同样的规则拼整行写回。
1387
+ const run = async (): Promise<MemoryEntry | null> => withWriteLock("store-write", async () => {
1388
+ const rows = await this.table!.query().where(`id = '${escapeSqlLiteral(fullId)}'`).limit(1).toArray();
1389
+ if (rows.length === 0) return null;
1390
+ const row = rows[0] as Record<string, unknown>;
1391
+ if (!matchesScopeFilter((row.scope as string | undefined) ?? "", scopeFilter)) {
1392
+ throw new Error(`Memory ${fullId} is outside accessible scopes`);
1393
+ }
1394
+ const entry = this.buildUpdatedEntry(row, {});
1165
1395
  let meta: Record<string, unknown>;
1166
1396
  try {
1167
1397
  const parsed: unknown = JSON.parse(entry.metadata || "{}");
@@ -1172,8 +1402,12 @@ export class MemoryStore implements MemoryStorePort {
1172
1402
  meta = {};
1173
1403
  }
1174
1404
  const patched = patchFn(meta, entry);
1175
- return this.update(fullId, { metadata: JSON.stringify(patched) }, scopeFilter);
1176
- };
1405
+ const updated: MemoryEntry = { ...entry, metadata: JSON.stringify(patched) };
1406
+ // 只更新已存在的行:delete() 不拿 store-write 锁,锁内读完之后这一行可能已被删掉(比如 forget),
1407
+ // 不能把它插回来(2026-09-24 上线前代码单审)
1408
+ const res = await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute([updated]);
1409
+ return res.numUpdatedRows > 0 ? updated : null;
1410
+ }, STORE_WRITE_LOCK_OPTS);
1177
1411
 
1178
1412
  const prev = this.metadataPatchQueues.get(fullId) ?? Promise.resolve();
1179
1413
  // Isolate prior failures so one rejected patch can't wedge the queue.
@@ -1277,9 +1511,10 @@ export class MemoryStore implements MemoryStorePort {
1277
1511
  }
1278
1512
  }
1279
1513
  if (toWrite.length === 0) return 0;
1280
- await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(toWrite);
1281
- return toWrite.length;
1282
- }, { expireMs: 30_000 });
1514
+ // 只更新已存在的行:锁内读完之后被不拿锁的 delete() 删掉的行不插回来,返回实际更新数(2026-09-24 上线前代码单审)
1515
+ const res = await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute(toWrite);
1516
+ return res.numUpdatedRows;
1517
+ }, STORE_WRITE_LOCK_OPTS);
1283
1518
  }
1284
1519
  return written;
1285
1520
  }
@@ -1296,7 +1531,7 @@ export class MemoryStore implements MemoryStorePort {
1296
1531
  async repairSingletonVersionGroups(): Promise<number> {
1297
1532
  await this.ensureInitialized();
1298
1533
 
1299
- return withWriteLock("store-write", async () => {
1534
+ return withWriteLock("store-write", async (signal) => {
1300
1535
  const membershipRows = await this.table!.query()
1301
1536
  .select(["id", "metadata"])
1302
1537
  .toArray();
@@ -1311,6 +1546,12 @@ export class MemoryStore implements MemoryStorePort {
1311
1546
  let repaired = 0;
1312
1547
  const BATCH = 200;
1313
1548
  for (let i = 0; i < repairs.length; i += BATCH) {
1549
+ // Past its max hold: stop between batches (each batch is an independent, re-checked
1550
+ // commit) so the lock goes back to other writers; the rest is repaired next pass.
1551
+ if (signal.aborted) {
1552
+ logWarn(`[store] repairSingletonVersionGroups stopped after ${repaired} of ${repairs.length} repairs: held store-write past its max hold`);
1553
+ break;
1554
+ }
1314
1555
  const batch = repairs.slice(i, i + BATCH);
1315
1556
  const expectedById = new Map(batch.map(item => [item.id, item.groupId]));
1316
1557
  const conditions = batch
@@ -1357,7 +1598,7 @@ export class MemoryStore implements MemoryStorePort {
1357
1598
  repaired += result.numUpdatedRows;
1358
1599
  }
1359
1600
  return repaired;
1360
- }, { expireMs: 30 * 60_000 });
1601
+ }, STORE_WRITE_LONG_LOCK_OPTS);
1361
1602
  }
1362
1603
 
1363
1604
  async bulkDelete(scopeFilter: string[], beforeTimestamp?: number): Promise<number> {
@@ -0,0 +1,25 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * 判重与对账共用的文本归一:去掉转录行首的「[用户]」「[助手]」标记,空白折叠。
5
+ * (原定义在 ingest.ts,2026-09-24 挪到这里,免得 forget 引擎为一个五行函数载入整个导入模块;ingest.ts 原样再导出,行为不变。)
6
+ */
7
+ export function normalizeDedupText(value: string): string {
8
+ return value
9
+ .replace(/^\[(用户|助手)\]\s*/gm, "")
10
+ .replace(/\s+/g, " ")
11
+ .trim();
12
+ }
13
+
14
+ /** 已归一文本的指纹(sha256 前 16 位十六进制)。 */
15
+ export function fingerprintNormalized(norm: string): string {
16
+ return createHash("sha256").update(norm).digest("hex").slice(0, 16);
17
+ }
18
+
19
+ /**
20
+ * 文本指纹:先归一再取指纹。forget 的审计事件记它(`norm=<指纹>`),记忆文件对账据此认出「被 forget 的是这段文字」,
21
+ * 不管当时删的是同文的哪一行、原文空白差多少——只记 id 的话,删掉一行,同文的另一行照样会被恢复回来。
22
+ */
23
+ export function textFingerprint(text: string): string {
24
+ return fingerprintNormalized(normalizeDedupText(text));
25
+ }