recallnest 2.3.0 → 2.5.4

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 (221) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +17 -31
  3. package/README_CN.md +12 -22
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +22 -1
  15. package/src/__tests__/access-tracker-novelty.test.ts +238 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/auto-consolidation.test.ts +168 -0
  21. package/src/__tests__/auto-recall.test.ts +131 -0
  22. package/src/__tests__/babel-integration.test.ts +144 -0
  23. package/src/__tests__/batch-dedup.test.ts +65 -0
  24. package/src/__tests__/canary-eval.test.ts +114 -0
  25. package/src/__tests__/capture-engine.test.ts +1238 -0
  26. package/src/__tests__/capture-heuristic.test.ts +171 -0
  27. package/src/__tests__/cascade-forget.test.ts +210 -0
  28. package/src/__tests__/category-min-scores.test.ts +33 -0
  29. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  30. package/src/__tests__/circuit-breaker.test.ts +99 -0
  31. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  32. package/src/__tests__/confidence-metadata.test.ts +125 -0
  33. package/src/__tests__/confidence-tracker.test.ts +261 -0
  34. package/src/__tests__/conflict-advisor.test.ts +206 -0
  35. package/src/__tests__/conflict-escalation.test.ts +149 -0
  36. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  37. package/src/__tests__/conflict-output.test.ts +85 -0
  38. package/src/__tests__/conflict-store.test.ts +374 -0
  39. package/src/__tests__/connector-v1.test.ts +181 -0
  40. package/src/__tests__/consolidation-engine.test.ts +333 -0
  41. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  42. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  43. package/src/__tests__/context-composer-pins.test.ts +64 -0
  44. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  45. package/src/__tests__/context-reconstructor.test.ts +65 -0
  46. package/src/__tests__/context-renderer.test.ts +91 -0
  47. package/src/__tests__/continuity-eval.test.ts +275 -0
  48. package/src/__tests__/continuity-schema.test.ts +97 -0
  49. package/src/__tests__/contracts.test.ts +216 -0
  50. package/src/__tests__/conversation-importer.test.ts +692 -0
  51. package/src/__tests__/core-recall-eval.test.ts +235 -0
  52. package/src/__tests__/core-summary.test.ts +109 -0
  53. package/src/__tests__/data-checkup.test.ts +276 -0
  54. package/src/__tests__/decay-exemption.test.ts +84 -0
  55. package/src/__tests__/dedup-actions.test.ts +58 -0
  56. package/src/__tests__/dedup-guard.test.ts +39 -0
  57. package/src/__tests__/deterministic-id.test.ts +39 -0
  58. package/src/__tests__/distill-lock.test.ts +305 -0
  59. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  60. package/src/__tests__/doctor.test.ts +319 -0
  61. package/src/__tests__/dream-pipeline.test.ts +321 -0
  62. package/src/__tests__/drill-down.test.ts +108 -0
  63. package/src/__tests__/embedder.test.ts +122 -0
  64. package/src/__tests__/emotion-decay.test.ts +46 -0
  65. package/src/__tests__/emotion-detector.test.ts +56 -0
  66. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  67. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  68. package/src/__tests__/entity-resolver.test.ts +58 -0
  69. package/src/__tests__/env-config.test.ts +151 -0
  70. package/src/__tests__/error-signature-boost.test.ts +87 -0
  71. package/src/__tests__/error-signature.test.ts +95 -0
  72. package/src/__tests__/error-taxonomy.test.ts +129 -0
  73. package/src/__tests__/ethics-layer.test.ts +476 -0
  74. package/src/__tests__/event-segmenter.test.ts +153 -0
  75. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  76. package/src/__tests__/frequency-tracker.test.ts +113 -0
  77. package/src/__tests__/graph-export.test.ts +489 -0
  78. package/src/__tests__/hotness-blend.test.ts +76 -0
  79. package/src/__tests__/ingest-chunking.test.ts +44 -0
  80. package/src/__tests__/ingest.test.ts +360 -0
  81. package/src/__tests__/interference-detector.test.ts +203 -0
  82. package/src/__tests__/kg-extractor.test.ts +192 -0
  83. package/src/__tests__/kg-store.test.ts +300 -0
  84. package/src/__tests__/llm-consolidation.test.ts +226 -0
  85. package/src/__tests__/maintenance-locks.test.ts +174 -0
  86. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  87. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  88. package/src/__tests__/memory-boundaries.test.ts +250 -0
  89. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  90. package/src/__tests__/memory-evolution.test.ts +271 -0
  91. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  92. package/src/__tests__/memory-lint.test.ts +421 -0
  93. package/src/__tests__/memory-output.test.ts +215 -0
  94. package/src/__tests__/memory-promotion.test.ts +315 -0
  95. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  96. package/src/__tests__/multi-vector.test.ts +242 -0
  97. package/src/__tests__/narrative-architecture.test.ts +456 -0
  98. package/src/__tests__/noise-filter.test.ts +205 -0
  99. package/src/__tests__/obsidian-connector.test.ts +363 -0
  100. package/src/__tests__/omitted-reporting.test.ts +98 -0
  101. package/src/__tests__/pending-review.test.ts +77 -0
  102. package/src/__tests__/pii-redact.test.ts +314 -0
  103. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  104. package/src/__tests__/ppr-traversal.test.ts +256 -0
  105. package/src/__tests__/prediction-engine.test.ts +349 -0
  106. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  107. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  108. package/src/__tests__/preference-matcher.test.ts +230 -0
  109. package/src/__tests__/preference-slots.test.ts +205 -0
  110. package/src/__tests__/prospective-memory.test.ts +283 -0
  111. package/src/__tests__/query-entity-detector.test.ts +171 -0
  112. package/src/__tests__/query-expander-alias.test.ts +146 -0
  113. package/src/__tests__/query-expander.test.ts +34 -0
  114. package/src/__tests__/recall-governor.test.ts +227 -0
  115. package/src/__tests__/recall-mode.test.ts +64 -0
  116. package/src/__tests__/result-synthesizer.test.ts +198 -0
  117. package/src/__tests__/resume-context.test.ts +4898 -0
  118. package/src/__tests__/retrieval-trace.test.ts +213 -0
  119. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  120. package/src/__tests__/retroactive-boost.test.ts +223 -0
  121. package/src/__tests__/salience-scoring.test.ts +106 -0
  122. package/src/__tests__/scope-inventory.test.ts +200 -0
  123. package/src/__tests__/scope-policy.test.ts +69 -0
  124. package/src/__tests__/scope-suggester.test.ts +80 -0
  125. package/src/__tests__/session-checkpoint.test.ts +291 -0
  126. package/src/__tests__/session-distiller.test.ts +640 -0
  127. package/src/__tests__/session-gc.test.ts +215 -0
  128. package/src/__tests__/skill-engine.test.ts +552 -0
  129. package/src/__tests__/skill-promotion.test.ts +601 -0
  130. package/src/__tests__/skill-verifier.test.ts +122 -0
  131. package/src/__tests__/source-heartbeat.test.ts +250 -0
  132. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  133. package/src/__tests__/store-scope-audit.test.ts +81 -0
  134. package/src/__tests__/store-upsert.test.ts +184 -0
  135. package/src/__tests__/supersede-chain.test.ts +154 -0
  136. package/src/__tests__/temporal-parser.test.ts +241 -0
  137. package/src/__tests__/temporal-validity.test.ts +160 -0
  138. package/src/__tests__/term-registry.test.ts +121 -0
  139. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  140. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  141. package/src/__tests__/topic-tag.test.ts +113 -0
  142. package/src/__tests__/trusted-memory.test.ts +839 -0
  143. package/src/__tests__/usage-tracker.test.ts +287 -0
  144. package/src/__tests__/version-manager.test.ts +213 -0
  145. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  146. package/src/__tests__/workflow-observation.test.ts +433 -0
  147. package/src/__tests__/write-verifier.test.ts +122 -0
  148. package/src/access-tracker.ts +38 -32
  149. package/src/activity-counter.ts +46 -18
  150. package/src/aliases.ts +133 -0
  151. package/src/api-server.ts +27 -7
  152. package/src/audit-log.ts +4 -2
  153. package/src/auto-consolidation.ts +8 -3
  154. package/src/auto-gc.ts +85 -21
  155. package/src/capture-engine.ts +54 -21
  156. package/src/cli.ts +169 -37
  157. package/src/confidence-tracker.ts +36 -18
  158. package/src/consolidation-engine.ts +109 -11
  159. package/src/context-composer.ts +8 -1
  160. package/src/data-checkup.ts +95 -6
  161. package/src/distill-lock.ts +194 -15
  162. package/src/doctor.ts +9 -2
  163. package/src/dream-pipeline.ts +118 -34
  164. package/src/env-config.ts +77 -0
  165. package/src/error-signature.ts +153 -0
  166. package/src/error-taxonomy.ts +115 -0
  167. package/src/eval.ts +251 -6
  168. package/src/idempotency.ts +8 -0
  169. package/src/ingest.ts +31 -10
  170. package/src/kg-extractor.ts +6 -2
  171. package/src/kg-store.ts +236 -44
  172. package/src/llm-consolidation.ts +2 -1
  173. package/src/mcp-server.ts +85 -1799
  174. package/src/mcp-tool-deps.ts +48 -0
  175. package/src/mcp-tools-advanced.ts +941 -0
  176. package/src/mcp-tools-core.ts +633 -0
  177. package/src/mcp-tools-governance.ts +475 -0
  178. package/src/memory-boundaries.ts +3 -1
  179. package/src/memory-evolution.ts +73 -1
  180. package/src/memory-lint.ts +183 -10
  181. package/src/memory-output.ts +114 -3
  182. package/src/memory-promotion.ts +246 -0
  183. package/src/memory-schema.ts +25 -2
  184. package/src/memory-store-port.ts +43 -0
  185. package/src/multi-vector.ts +2 -1
  186. package/src/narrative-schema.ts +2 -1
  187. package/src/noise-filter.ts +57 -6
  188. package/src/pii-detector.ts +157 -5
  189. package/src/query-expander.ts +90 -9
  190. package/src/result-synthesizer.ts +2 -1
  191. package/src/retention-policy.ts +2 -1
  192. package/src/retriever.ts +259 -139
  193. package/src/runtime-config.ts +8 -1
  194. package/src/scope-suggester.ts +113 -0
  195. package/src/session-distiller.ts +23 -2
  196. package/src/session-engine.ts +2 -1
  197. package/src/session-schema.ts +1 -0
  198. package/src/session-store.ts +5 -0
  199. package/src/skill-engine.ts +78 -0
  200. package/src/skill-promotion.ts +247 -47
  201. package/src/skill-schema.ts +16 -3
  202. package/src/skill-verifier.ts +152 -0
  203. package/src/store.ts +267 -38
  204. package/src/tool-output-compressor.ts +3 -1
  205. package/src/ui-server.ts +2 -1
  206. package/src/usage-tracker.ts +242 -0
  207. package/src/version-manager.ts +3 -2
  208. package/src/workflow-observation-engine.ts +2 -1
  209. package/src/workflow-observation-schema.ts +2 -1
  210. package/src/workflow-observation-store.ts +6 -2
  211. package/.env +0 -4
  212. package/CLAUDE.md +0 -46
  213. package/config.json +0 -55
  214. package/daily-recall.sh +0 -110
  215. package/logs/daily-recall.error.log +0 -0
  216. package/logs/daily-recall.log +0 -60
  217. package/logs/ingest-2026-04-11.log +0 -110
  218. package/logs/ingest-2026-04-12.log +0 -90
  219. package/logs/ingest-2026-04-13.log +0 -87
  220. package/logs/launchagent-stderr.log +0 -0
  221. package/logs/launchagent-stdout.log +0 -0
package/src/kg-store.ts CHANGED
@@ -19,15 +19,25 @@ export interface KGTriple {
19
19
  subject: string;
20
20
  predicate: string;
21
21
  object: string;
22
- /** Extraction confidence 0-1 */
22
+ /** Extraction confidence 0-1 (max across all extractions of this triple) */
23
23
  confidence: number;
24
- /** ID of the source memory entry */
24
+ /** First source memory that contributed this triple (read-path compat) */
25
25
  source_memory_id: string;
26
26
  /** Original text snippet used for extraction */
27
27
  source_text: string;
28
+ /** Last time this triple was (re-)extracted */
28
29
  timestamp: number;
30
+ /** Number of distinct source memories that mentioned this fact (frequency = importance signal) */
31
+ mention_count: number;
32
+ /** Timestamp of the first extraction of this triple */
33
+ first_seen: number;
34
+ /** JSON array of contributing source memory ids, capped at SOURCE_IDS_CAP */
35
+ source_memory_ids: string;
29
36
  }
30
37
 
38
+ /** Caller-facing input: counting fields are managed internally by the store. */
39
+ export type KGTripleInput = Omit<KGTriple, "id" | "timestamp" | "mention_count" | "first_seen" | "source_memory_ids">;
40
+
31
41
  export interface KGStoreConfig {
32
42
  /** Reuse the same LanceDB connection path as MemoryStore */
33
43
  dbPath: string;
@@ -56,6 +66,25 @@ function escapeSql(value: string): string {
56
66
  return value.replace(/'/g, "''");
57
67
  }
58
68
 
69
+ /**
70
+ * Cap on the source_memory_ids list (row-bloat guard). mention_count itself is
71
+ * NOT capped: once the list is full, a returning 201st+ source can no longer be
72
+ * recognized and would double-count on re-extraction. Accepted error — a fact
73
+ * mentioned by 200+ distinct memories is extreme head-of-distribution either way.
74
+ */
75
+ const SOURCE_IDS_CAP = 200;
76
+
77
+ /** Parse the JSON source id list, falling back to the legacy single-id column. */
78
+ export function parseSourceIds(raw: string | undefined | null, fallbackId?: string): string[] {
79
+ if (raw) {
80
+ try {
81
+ const arr = JSON.parse(raw);
82
+ if (Array.isArray(arr)) return arr.filter((x): x is string => typeof x === "string");
83
+ } catch { /* malformed — fall back below */ }
84
+ }
85
+ return fallbackId ? [fallbackId] : [];
86
+ }
87
+
59
88
  // ============================================================================
60
89
  // KG Store
61
90
  // ============================================================================
@@ -64,9 +93,19 @@ export class KGStore {
64
93
  private db: LanceDB.Connection | null = null;
65
94
  private table: LanceDB.Table | null = null;
66
95
  private initPromise: Promise<void> | null = null;
96
+ /** In-process write serialization: concurrent workers (e.g. backfill
97
+ * --concurrency) would otherwise race read-modify-write AND collide on
98
+ * LanceDB optimistic commits. Cross-process races stay best-effort. */
99
+ private writeChain: Promise<unknown> = Promise.resolve();
67
100
 
68
101
  constructor(private readonly config: KGStoreConfig) {}
69
102
 
103
+ private withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
104
+ const run = this.writeChain.then(fn, fn);
105
+ this.writeChain = run.catch(() => {});
106
+ return run;
107
+ }
108
+
70
109
  // --------------------------------------------------------------------------
71
110
  // Initialization
72
111
  // --------------------------------------------------------------------------
@@ -101,6 +140,9 @@ export class KGStore {
101
140
  source_memory_id: "",
102
141
  source_text: "",
103
142
  timestamp: 0,
143
+ mention_count: 0,
144
+ first_seen: 0,
145
+ source_memory_ids: "[]",
104
146
  };
105
147
 
106
148
  try {
@@ -115,6 +157,31 @@ export class KGStore {
115
157
  }
116
158
  }
117
159
 
160
+ // Defensive migration for pre-mention-count tables. Production has never
161
+ // created this table (KG mode was never enabled), so this only guards
162
+ // stale dev/test databases. Best-effort: on failure we log and continue —
163
+ // rowToTriple() still reads legacy rows via fallbacks.
164
+ try {
165
+ const schema = await table.schema();
166
+ const fields = new Set(schema.fields.map((f) => f.name));
167
+ const missing: Array<{ name: string; valueSql: string }> = [];
168
+ if (!fields.has("mention_count")) missing.push({ name: "mention_count", valueSql: "1" });
169
+ // Constant 0 (not a column reference — SQL-dialect-proof); rowToTriple treats
170
+ // first_seen<=0 as "unknown" and falls back to the row's timestamp.
171
+ if (!fields.has("first_seen")) missing.push({ name: "first_seen", valueSql: "0" });
172
+ if (!fields.has("source_memory_ids")) {
173
+ missing.push({ name: "source_memory_ids", valueSql: `concat('["', source_memory_id, '"]')` });
174
+ }
175
+ if (missing.length > 0) {
176
+ await table.addColumns(missing);
177
+ }
178
+ } catch (err) {
179
+ console.error(
180
+ "[recallnest] KG schema migration check failed (legacy rows still readable via fallbacks):",
181
+ err instanceof Error ? err.message : String(err),
182
+ );
183
+ }
184
+
118
185
  // Create FTS index on subject for entity lookup (optional)
119
186
  try {
120
187
  const indices = await table.listIndices();
@@ -138,53 +205,93 @@ export class KGStore {
138
205
  // Write
139
206
  // --------------------------------------------------------------------------
140
207
 
141
- async createTriple(triple: Omit<KGTriple, "id" | "timestamp">): Promise<KGTriple> {
142
- await this.ensureInitialized();
143
-
144
- const full: KGTriple = {
145
- ...triple,
146
- id: tripleId(triple.scope, triple.subject, triple.predicate, triple.object),
147
- timestamp: Date.now(),
148
- };
149
-
150
- // Upsert: delete old then add new (LanceDB has no native upsert)
151
- try {
152
- await this.table!.delete(`id = '${escapeSql(full.id)}'`);
153
- } catch {
154
- // OK if not found
155
- }
156
- await this.table!.add([full]);
157
- return full;
208
+ async createTriple(triple: KGTripleInput): Promise<KGTriple> {
209
+ const [result] = await this.createTriples([triple]);
210
+ return result;
158
211
  }
159
212
 
160
- async createTriples(triples: Array<Omit<KGTriple, "id" | "timestamp">>): Promise<KGTriple[]> {
213
+ /**
214
+ * Upsert triples with mention counting (Memori-style: a repeated fact does
215
+ * not create a new row — its mention_count grows and timestamp refreshes).
216
+ *
217
+ * Counting semantics:
218
+ * - mention_count = number of DISTINCT source memories that contributed the triple
219
+ * - the same source memory re-contributing (e.g. memory re-edit) refreshes
220
+ * timestamp and confidence (max) but does NOT increment the count
221
+ * - first_seen is preserved from the first extraction
222
+ *
223
+ * Concurrency: read-modify-write — two processes racing on the same triple id
224
+ * may lose one count increment (accepted: the count is a best-effort signal).
225
+ * Row loss is NOT possible: the final write is an atomic mergeInsert, the same
226
+ * primitive the main memories table adopted in the 2026-07-02 surgery.
227
+ */
228
+ async createTriples(triples: KGTripleInput[]): Promise<KGTriple[]> {
161
229
  if (triples.length === 0) return [];
162
230
  await this.ensureInitialized();
231
+ return this.withWriteLock(() => this.createTriplesLocked(triples));
232
+ }
163
233
 
234
+ private async createTriplesLocked(triples: KGTripleInput[]): Promise<KGTriple[]> {
164
235
  const now = Date.now();
165
- const fullTriples: KGTriple[] = triples.map((t) => ({
166
- ...t,
167
- id: tripleId(t.scope, t.subject, t.predicate, t.object),
168
- timestamp: now,
169
- }));
170
-
171
- // Deduplicate by id (same triple in batch)
172
- const seen = new Set<string>();
173
- const deduped = fullTriples.filter((t) => {
174
- if (seen.has(t.id)) return false;
175
- seen.add(t.id);
176
- return true;
177
- });
178
236
 
179
- // Batch delete existing then add
180
- const ids = deduped.map((t) => `'${escapeSql(t.id)}'`).join(", ");
181
- try {
182
- await this.table!.delete(`id IN (${ids})`);
183
- } catch {
184
- // OK if none found
237
+ // 1. Aggregate within batch: same triple id → union of contributing sources
238
+ const byId = new Map<string, { input: KGTripleInput; sources: string[]; confidence: number }>();
239
+ for (const t of triples) {
240
+ const id = tripleId(t.scope, t.subject, t.predicate, t.object);
241
+ const agg = byId.get(id);
242
+ if (agg) {
243
+ if (!agg.sources.includes(t.source_memory_id)) agg.sources.push(t.source_memory_id);
244
+ agg.confidence = Math.max(agg.confidence, t.confidence);
245
+ } else {
246
+ byId.set(id, { input: t, sources: [t.source_memory_id], confidence: t.confidence });
247
+ }
185
248
  }
186
- await this.table!.add(deduped);
187
- return deduped;
249
+
250
+ // 2. Fetch existing rows for these ids
251
+ const idList = [...byId.keys()].map((id) => `'${escapeSql(id)}'`).join(", ");
252
+ const existingRows = await this.table!.query().where(`id IN (${idList})`).limit(byId.size).toArray();
253
+ const existingById = new Map<string, KGTriple>(
254
+ existingRows.map((r) => [r.id as string, rowToTriple(r)]),
255
+ );
256
+
257
+ // 3. Merge counts in memory
258
+ const toWrite: KGTriple[] = [];
259
+ for (const [id, agg] of byId) {
260
+ const prev = existingById.get(id);
261
+ if (prev) {
262
+ const knownIds = parseSourceIds(prev.source_memory_ids, prev.source_memory_id);
263
+ const knownSet = new Set(knownIds);
264
+ let count = prev.mention_count;
265
+ for (const s of agg.sources) {
266
+ if (knownSet.has(s)) continue;
267
+ count++;
268
+ knownSet.add(s);
269
+ if (knownIds.length < SOURCE_IDS_CAP) knownIds.push(s);
270
+ }
271
+ toWrite.push({
272
+ ...prev,
273
+ confidence: Math.max(prev.confidence, agg.confidence),
274
+ timestamp: now,
275
+ mention_count: count,
276
+ source_memory_ids: JSON.stringify(knownIds),
277
+ });
278
+ } else {
279
+ toWrite.push({
280
+ ...agg.input,
281
+ id,
282
+ confidence: agg.confidence,
283
+ source_memory_id: agg.sources[0],
284
+ timestamp: now,
285
+ mention_count: agg.sources.length,
286
+ first_seen: now,
287
+ source_memory_ids: JSON.stringify(agg.sources.slice(0, SOURCE_IDS_CAP)),
288
+ });
289
+ }
290
+ }
291
+
292
+ // 4. Atomic idempotent write
293
+ await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(toWrite);
294
+ return toWrite;
188
295
  }
189
296
 
190
297
  // --------------------------------------------------------------------------
@@ -272,6 +379,35 @@ export class KGStore {
272
379
  return results;
273
380
  }
274
381
 
382
+ /**
383
+ * Batch reverse lookup: which triples did each of these memories contribute?
384
+ * Used by the consolidation engine as a second merge-evidence source.
385
+ * A multi-source triple appears under every contributing memory id.
386
+ */
387
+ async getTriplesBySourceMemories(memoryIds: string[]): Promise<Map<string, KGTriple[]>> {
388
+ const result = new Map<string, KGTriple[]>();
389
+ if (memoryIds.length === 0) return result;
390
+ await this.ensureInitialized();
391
+
392
+ const conds = memoryIds.map((id) => {
393
+ const safe = escapeSql(id);
394
+ return `source_memory_id = '${safe}' OR source_memory_ids LIKE '%"${safe}"%'`;
395
+ });
396
+ const rows = await this.table!.query().where(conds.join(" OR ")).limit(100000).toArray();
397
+
398
+ for (const row of rows) {
399
+ const t = rowToTriple(row);
400
+ const contributors = parseSourceIds(t.source_memory_ids, t.source_memory_id);
401
+ for (const mid of memoryIds) {
402
+ if (!contributors.includes(mid)) continue; // LIKE prefilter false positive guard
403
+ const list = result.get(mid);
404
+ if (list) list.push(t);
405
+ else result.set(mid, [t]);
406
+ }
407
+ }
408
+ return result;
409
+ }
410
+
275
411
  // --------------------------------------------------------------------------
276
412
  // Read — Entity listing
277
413
  // --------------------------------------------------------------------------
@@ -311,10 +447,57 @@ export class KGStore {
311
447
  // Delete
312
448
  // --------------------------------------------------------------------------
313
449
 
314
- /** Delete all triples extracted from a given source memory */
450
+ /**
451
+ * Remove a source memory's contribution from the KG (forget path).
452
+ *
453
+ * Multi-source semantics: a triple evidenced by OTHER memories survives with
454
+ * its count decremented — forgetting one memory must not erase a fact that
455
+ * other memories still prove. Only when the last evidence is removed does the
456
+ * row get deleted. If the forgotten memory supplied the stored source_text
457
+ * snippet, the snippet is cleared (no forgotten-content residue).
458
+ */
315
459
  async deleteBySource(sourceMemoryId: string): Promise<void> {
316
460
  await this.ensureInitialized();
317
- await this.table!.delete(`source_memory_id = '${escapeSql(sourceMemoryId)}'`);
461
+ const safe = escapeSql(sourceMemoryId);
462
+
463
+ // LIKE on the JSON array is an exact match thanks to the quotes:
464
+ // '%"mem-1"%' never matches "mem-12". source_memory_id covers legacy rows.
465
+ const rows = await this.table!.query()
466
+ .where(`source_memory_id = '${safe}' OR source_memory_ids LIKE '%"${safe}"%'`)
467
+ .limit(100000)
468
+ .toArray();
469
+ if (rows.length === 0) return;
470
+
471
+ const toDelete: string[] = [];
472
+ const toUpdate: KGTriple[] = [];
473
+ for (const row of rows) {
474
+ const t = rowToTriple(row);
475
+ const ids = parseSourceIds(t.source_memory_ids, t.source_memory_id);
476
+ if (!ids.includes(sourceMemoryId)) continue; // LIKE prefilter false positive — exact check in JS
477
+ const remaining = ids.filter((s) => s !== sourceMemoryId);
478
+ if (remaining.length === 0) {
479
+ toDelete.push(t.id);
480
+ } else {
481
+ toUpdate.push({
482
+ ...t,
483
+ // count never drops below the remaining evidence count (cap-era divergence guard)
484
+ mention_count: Math.max(t.mention_count - 1, remaining.length),
485
+ source_memory_id: remaining[0],
486
+ source_memory_ids: JSON.stringify(remaining),
487
+ source_text: t.source_memory_id === sourceMemoryId ? "" : t.source_text,
488
+ });
489
+ }
490
+ }
491
+
492
+ await this.withWriteLock(async () => {
493
+ if (toUpdate.length > 0) {
494
+ await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(toUpdate);
495
+ }
496
+ if (toDelete.length > 0) {
497
+ const idList = toDelete.map((id) => `'${escapeSql(id)}'`).join(", ");
498
+ await this.table!.delete(`id IN (${idList})`);
499
+ }
500
+ });
318
501
  }
319
502
 
320
503
  /** Delete all triples in scope */
@@ -340,6 +523,7 @@ export class KGStore {
340
523
  // ============================================================================
341
524
 
342
525
  function rowToTriple(row: Record<string, unknown>): KGTriple {
526
+ const sourceMemoryId = row.source_memory_id as string;
343
527
  return {
344
528
  id: row.id as string,
345
529
  scope: (row.scope as string) ?? "global",
@@ -347,8 +531,16 @@ function rowToTriple(row: Record<string, unknown>): KGTriple {
347
531
  predicate: row.predicate as string,
348
532
  object: row.object as string,
349
533
  confidence: Number(row.confidence),
350
- source_memory_id: row.source_memory_id as string,
534
+ source_memory_id: sourceMemoryId,
351
535
  source_text: row.source_text as string,
352
536
  timestamp: Number(row.timestamp),
537
+ // Legacy-row fallbacks (pre-mention-count schema; first_seen<=0 = migrated placeholder)
538
+ mention_count: row.mention_count != null ? Number(row.mention_count) : 1,
539
+ first_seen:
540
+ row.first_seen != null && Number(row.first_seen) > 0 ? Number(row.first_seen) : Number(row.timestamp),
541
+ source_memory_ids:
542
+ typeof row.source_memory_ids === "string" && row.source_memory_ids.length > 0
543
+ ? row.source_memory_ids
544
+ : JSON.stringify(sourceMemoryId ? [sourceMemoryId] : []),
353
545
  };
354
546
  }
@@ -16,6 +16,7 @@ import type { LLMClient } from "./llm-client.js";
16
16
  import type { MemoryEntry } from "./store.js";
17
17
  import { createVersionGroup } from "./version-manager.js";
18
18
  import { logInfo, logWarn } from "./stderr-log.js";
19
+ import * as envConfig from "./env-config.js";
19
20
 
20
21
  // ============================================================================
21
22
  // Configuration
@@ -25,7 +26,7 @@ import { logInfo, logWarn } from "./stderr-log.js";
25
26
  * Check if LLM-driven consolidation is enabled.
26
27
  */
27
28
  export function isLLMConsolidationEnabled(): boolean {
28
- return process.env.RECALLNEST_LLM_CONSOLIDATION === "true";
29
+ return envConfig.llmConsolidation();
29
30
  }
30
31
 
31
32
  // ============================================================================