@remnic/core 9.3.747 → 9.3.749

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.
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Persistence-index coordinator — extracted from the orchestrator
3
+ * (issue #1526, seam 23).
4
+ *
5
+ * Owns the post-persist bookkeeping that extraction-persist and
6
+ * consolidation delegate back through the orchestrator:
7
+ * - content-hash dedup index add/has/remove/save
8
+ * - temporal-bounds backfill on dedup hits (bitemporal, #1578)
9
+ * - temporal tag index updates and persisted-memory indexing
10
+ * - graph edge construction for newly persisted memories
11
+ * - semantic dedup candidate lookup
12
+ *
13
+ * Behavior-preserving move from orchestrator.ts (late-binding deps rule,
14
+ * seams 18–22).
15
+ */
16
+
17
+ import path from "node:path";
18
+ import { type GraphConstructionCapabilitySet, resolveCapabilities, resolveGraphConstructionCapabilities, resolveIndexingCapabilities, resolveMemoryLifecycleCapabilities, resolveNamespaceCapabilities } from "../capabilities.js";
19
+ import type { SemanticDedupHit } from "../dedup/semantic.js";
20
+ import { EmbeddingFallback } from "../embedding-fallback.js";
21
+ import { GraphIndex } from "../graph.js";
22
+ import { ContentHashIndex, StorageManager } from "../index.js";
23
+ import { log } from "../logger.js";
24
+ import { isActiveMemoryStatus } from "../memory-lifecycle-ledger-utils.js";
25
+ import { stripCitationForTemplate } from "../source-attribution.js";
26
+ import { clearIndexes, indexMemoriesBatch, indexesExist } from "../temporal-index.js";
27
+ import { normalizeSupersessionKey } from "../temporal-supersession.js";
28
+ import type { MemoryFile, MemoryFrontmatter, PluginConfig } from "../types.js";
29
+ import {
30
+ resolveRecentThreadMemoryPaths,
31
+ } from "../orchestrator.js";
32
+
33
+ export interface PersistenceIndexDeps {
34
+ readonly config: PluginConfig;
35
+ readonly contentHashIndex: ContentHashIndex | null;
36
+ contentHashIndexForStorage(
37
+ targetStorage: StorageManager,
38
+ ): Promise<ContentHashIndex | null>;
39
+ readonly contentHashIndexesByStorageDir: Map<string, ContentHashIndex>;
40
+ readonly embeddingFallback: EmbeddingFallback;
41
+ graphIndexFor(storage: StorageManager): GraphIndex;
42
+ readAllMemoriesForNamespaces(
43
+ namespaces: string[],
44
+ ): Promise<MemoryFile[]>;
45
+ semanticDedupScopeFor(targetStorage: StorageManager): {
46
+ pathPrefix?: string;
47
+ pathExcludePrefixes?: readonly string[];
48
+ };
49
+ }
50
+
51
+ export class PersistenceIndexCoordinator {
52
+ constructor(
53
+ private readonly deps: PersistenceIndexDeps,
54
+ ) {}
55
+
56
+ async hasContentHashDedup(
57
+ targetStorage: StorageManager,
58
+ content: string,
59
+ ): Promise<boolean> {
60
+ const index = await this.deps.contentHashIndexForStorage(targetStorage);
61
+ return index ? index.has(content) : false;
62
+ }
63
+
64
+ async addContentHashDedup(
65
+ targetStorage: StorageManager,
66
+ content: string,
67
+ ): Promise<void> {
68
+ const index = await this.deps.contentHashIndexForStorage(targetStorage);
69
+ if (!index) return;
70
+ index.add(content);
71
+ }
72
+
73
+ async removeContentHashForMemory(
74
+ targetStorage: StorageManager,
75
+ memory: MemoryFile,
76
+ context: string,
77
+ ): Promise<void> {
78
+ const index = await this.deps.contentHashIndexForStorage(targetStorage);
79
+ if (!index) return;
80
+
81
+ if (memory.frontmatter.contentHash) {
82
+ index.removeByHash(memory.frontmatter.contentHash);
83
+ return;
84
+ }
85
+
86
+ log.warn(
87
+ `[${context}] removing hash for legacy memory ${memory.frontmatter.id ?? "(unknown)"} via content fallback - no contentHash in frontmatter`,
88
+ );
89
+ index.remove(memory.content);
90
+ }
91
+
92
+ /**
93
+ * Issue #1671 — backfill bi-temporal bounds onto an existing promoted/deduped
94
+ * copy that was written BEFORE the source fact carried a resolved
95
+ * `invalid_at`/`observedAt`/`eventTimeSource`.
96
+ *
97
+ * On re-extraction/backfill, a fact may now carry a resolved end bound (e.g.
98
+ * "until June 2025") that the existing copy lacks because it was promoted
99
+ * before bi-temporal wiring existed. Without this backfill, recall keeps
100
+ * surfacing an expired fact even though the source copy now expires correctly.
101
+ *
102
+ * Finds the active fact in `targetStorage` matching `dedupContent`, then
103
+ * patches the temporal frontmatter the existing copy is missing. Best-effort
104
+ * / fail-open — any I/O error is logged and swallowed so the dedup
105
+ * short-circuit is never blocked by a backfill failure.
106
+ *
107
+ * Matching: the stored `frontmatter.contentHash` is compared against
108
+ * `ContentHashIndex.computeHash(dedupContent)` first (the exact hash the
109
+ * content-hash index uses), then falls back to stripping citations and
110
+ * comparing normalized bodies. This handles inline-attribution deployments
111
+ * where the persisted body carries a citation marker the dedup key does not.
112
+ *
113
+ * I/O gate: only triggers when `invalidAt` is present (the end bound that
114
+ * actually changes recall behavior by expiring the fact). `observedAt` and
115
+ * `eventTimeSource` alone don'\''t expire a fact, so backfilling them without
116
+ * an end bound would cause a full readAllMemories scan on every dedup hit
117
+ * under biTemporal for no recall benefit.
118
+ *
119
+ * Only patches fields the existing copy LACKS — never overwrites a bound the
120
+ * copy already carries.
121
+ */
122
+ async backfillTemporalBoundsOnDedupHit(
123
+ targetStorage: StorageManager,
124
+ dedupContent: string,
125
+ bounds: {
126
+ invalidAt?: string;
127
+ // #1707 thread 2 — per-fact start bound (valid_at). Carried so a
128
+ // re-extracted duplicate whose event time yields only a start bound
129
+ // ("since 2024", "yesterday", an absolute date) gets the corrected
130
+ // per-fact anchoring onto the existing copy.
131
+ validFrom?: string;
132
+ observedAt?: string;
133
+ eventTimeSource?: "extracted" | "assumed";
134
+ },
135
+ entityRef?: string,
136
+ ): Promise<void> {
137
+ // I/O gate: scan when there is a recall-relevant bound to backfill —
138
+ // either an end bound (invalidAt, which expires the fact) or a corrected
139
+ // EXTRACTED start bound. A start bound only changes recall when it is
140
+ // extracted (the as-of filter excludes facts whose valid_at is after the
141
+ // as-of instant); an "assumed" validFrom is just the ingestion anchor
142
+ // (resolveFactEventTime sets one for every fact), so scanning on it would
143
+ // run a full readAllMemories on every bi-temporal dedup hit for no benefit
144
+ // (review cursor PRRT_OvHk / codex PRRT_OvHxVH). observedAt and
145
+ // eventTimeSource alone never change recall.
146
+ const hasExtractedStart =
147
+ bounds.validFrom !== undefined && bounds.eventTimeSource === "extracted";
148
+ if (!bounds.invalidAt && !hasExtractedStart) return;
149
+ try {
150
+ const incomingHash = ContentHashIndex.computeHash(dedupContent);
151
+ const normalizedIncoming = ContentHashIndex.normalizeContent(dedupContent);
152
+ // Normalize the entity for same-entity scoping when provided — two
153
+ // entities can share identical fact text, and patching a different
154
+ // entity'\''s fact would corrupt its temporal bounds (cursor review).
155
+ const incomingEntityNorm = entityRef
156
+ ? normalizeSupersessionKey(entityRef)
157
+ : undefined;
158
+ const all = await targetStorage.readAllMemories();
159
+ const existing = all.find((m) => {
160
+ if (m.frontmatter.category !== "fact") return false;
161
+ if ((m.frontmatter.status ?? "active") !== "active") return false;
162
+ // Same-entity guard: reject only when the stored fact carries a
163
+ // DIFFERENT entity (two entities can share identical fact text, so
164
+ // patching the other entity's copy would corrupt its bounds — codex
165
+ // P2 PRRT_OvB4A). Legacy facts written before entity linkage (no
166
+ // entityRef) have no entity to conflict with, so they stay eligible
167
+ // for backfill (cursor PRRT_OvKnV: the guard must NOT silently
168
+ // no-op for older promoted copies that predate entity linkage).
169
+ if (
170
+ incomingEntityNorm &&
171
+ m.frontmatter.entityRef &&
172
+ normalizeSupersessionKey(m.frontmatter.entityRef) !== incomingEntityNorm
173
+ ) {
174
+ return false;
175
+ }
176
+ // Prefer the stored contentHash (what the hash index actually keys
177
+ // on) — it is computed from contentHashSource (the raw/enriched
178
+ // body before citation), matching the dedupContent the caller passes.
179
+ if (m.frontmatter.contentHash) {
180
+ return m.frontmatter.contentHash === incomingHash;
181
+ }
182
+ // Legacy facts without a stored hash: strip citations then compare
183
+ // normalized bodies so inline-attribution markers don'\''t prevent
184
+ // a match.
185
+ return (
186
+ ContentHashIndex.normalizeContent(
187
+ stripCitationForTemplate(m.content ?? "", this.deps.config.inlineSourceAttributionFormat),
188
+ ) === normalizedIncoming
189
+ );
190
+ });
191
+ if (!existing) return;
192
+ // Build a patch containing ONLY the fields the existing copy lacks.
193
+ const patch: Partial<MemoryFrontmatter> = {};
194
+ const fm = existing.frontmatter;
195
+ if (bounds.invalidAt && (!fm.invalid_at || fm.invalid_at.length === 0)) {
196
+ patch.invalid_at = bounds.invalidAt;
197
+ }
198
+ if (bounds.observedAt && (!fm.observedAt || fm.observedAt.length === 0)) {
199
+ patch.observedAt = bounds.observedAt;
200
+ }
201
+ if (
202
+ bounds.eventTimeSource &&
203
+ (!fm.eventTimeSource || fm.eventTimeSource.length === 0)
204
+ ) {
205
+ patch.eventTimeSource = bounds.eventTimeSource;
206
+ }
207
+ // #1707 thread 2 — per-fact-anchored start bound. A re-extracted
208
+ // duplicate whose event time resolves a real start bound must carry
209
+ // that anchor onto the existing copy so as-of recall uses the corrected
210
+ // valid_at instead of a stale batch-anchored value. Only an EXTRACTED
211
+ // bound corrects; an "assumed" bound is just the ingestion anchor.
212
+ //
213
+ // No-clobber via equality, not provenance inference: exact-content dedup
214
+ // re-extracts the SAME event-time expression, which #1670 per-fact
215
+ // anchoring resolves deterministically to the same validFrom — so for
216
+ // stable content the incoming validFrom EQUALS the copy's valid_at and
217
+ // we skip the redundant write (the only no-clobber that holds without a
218
+ // fragile provenance heuristic — review codex PRRT_Ov7LKC). When they
219
+ // differ (a prior batch/assumed anchor, end-only assumed start, or a
220
+ // non-deterministic re-resolution), the extracted validFrom is the
221
+ // authoritative correction and overwrites. The eventTimeSource upgrade
222
+ // below records that the start is now extracted-anchored.
223
+ if (
224
+ bounds.validFrom &&
225
+ bounds.eventTimeSource === "extracted" &&
226
+ fm.valid_at !== bounds.validFrom
227
+ ) {
228
+ patch.valid_at = bounds.validFrom;
229
+ // Mark the copy extracted-anchored in the SAME patch so its provenance
230
+ // reflects the correction (review cursor PRRT_OvHM / codex PRRT_OvHxVD):
231
+ // without this, a copy upgraded from "assumed" would keep "assumed"
232
+ // provenance while carrying an extracted start. (The earlier
233
+ // eventTimeSource block only fills an EMPTY source.)
234
+ if (fm.eventTimeSource !== "extracted") {
235
+ patch.eventTimeSource = "extracted";
236
+ }
237
+ }
238
+ if (Object.keys(patch).length === 0) return;
239
+ const ok = await targetStorage.writeMemoryFrontmatter(existing, patch);
240
+ if (ok) {
241
+ log.debug(
242
+ `bitemporal-backfill: patched ${Object.keys(patch).join(",")} onto existing fact ${fm.id ?? "(unknown)"} in ${targetStorage.dir}`,
243
+ );
244
+ }
245
+ } catch (err) {
246
+ log.warn(
247
+ `bitemporal-backfill: failed open for ${targetStorage.dir}: ${err}`,
248
+ );
249
+ }
250
+ }
251
+
252
+ async saveContentHashIndexes(): Promise<void> {
253
+ const indexes = new Set<ContentHashIndex>();
254
+ if (this.deps.contentHashIndex) indexes.add(this.deps.contentHashIndex);
255
+ for (const index of this.deps.contentHashIndexesByStorageDir.values()) {
256
+ indexes.add(index);
257
+ }
258
+ for (const index of indexes) {
259
+ await index.save();
260
+ }
261
+ }
262
+
263
+ async indexPersistedMemory(
264
+ storage: StorageManager,
265
+ memoryId: string,
266
+ ): Promise<void> {
267
+ if (!resolveMemoryLifecycleCapabilities(this.deps.config).embeddingFallback) return;
268
+ if (!(await this.deps.embeddingFallback.isAvailable())) return;
269
+ const memory = await storage.getMemoryById(memoryId);
270
+ if (!memory) return;
271
+ await this.deps.embeddingFallback.indexFile(
272
+ memoryId,
273
+ memory.content,
274
+ memory.path,
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Build a graph edge for a persisted memory (v8.2).
280
+ * Shared helper used by both the chunked and non-chunked write paths to avoid duplication.
281
+ * Fail-open: caller wraps in try/catch.
282
+ */
283
+ async buildGraphEdge(
284
+ storage: StorageManager,
285
+ memoryRelPath: string,
286
+ entityRef: string | undefined,
287
+ memoryId: string,
288
+ factContent: string,
289
+ allMemsForGraph: import("../types.js").MemoryFile[] | null | undefined,
290
+ memoryPathById: Map<string, string>,
291
+ threadIdForEdge: string | undefined,
292
+ threadEpisodeIdsForGraph: string[] | undefined,
293
+ fallbackCausalPredecessor: string | undefined,
294
+ graphCaps: GraphConstructionCapabilitySet = resolveGraphConstructionCapabilities(this.deps.config),
295
+ ): Promise<void> {
296
+ // Entity siblings: other memories sharing the same entityRef
297
+ const entitySiblings: string[] = [];
298
+ if (entityRef) {
299
+ try {
300
+ const allMems = allMemsForGraph ?? [];
301
+ for (const m of allMems) {
302
+ if (m.frontmatter.entityRef === entityRef) {
303
+ const rel = path.relative(storage.dir, m.path);
304
+ if (rel !== memoryRelPath) entitySiblings.push(rel);
305
+ }
306
+ }
307
+ } catch {
308
+ /* fail-open */
309
+ }
310
+ }
311
+ // Recent thread memories for time graph
312
+ const recentInThread: string[] = [];
313
+ if (threadIdForEdge && threadEpisodeIdsForGraph?.length) {
314
+ try {
315
+ recentInThread.push(
316
+ ...resolveRecentThreadMemoryPaths({
317
+ threadEpisodeIds: threadEpisodeIdsForGraph,
318
+ currentMemoryId: memoryId,
319
+ allMemsForGraph,
320
+ pathById: memoryPathById,
321
+ storageDir: storage.dir,
322
+ maxRecent: 3,
323
+ }),
324
+ );
325
+ } catch {
326
+ /* fail-open */
327
+ }
328
+ }
329
+ if (
330
+ recentInThread.length === 0 &&
331
+ graphCaps.graphWriteSessionAdjacency &&
332
+ fallbackCausalPredecessor &&
333
+ fallbackCausalPredecessor !== memoryRelPath
334
+ ) {
335
+ recentInThread.push(fallbackCausalPredecessor);
336
+ }
337
+ const causalPredecessor =
338
+ recentInThread[recentInThread.length - 1] ?? fallbackCausalPredecessor;
339
+ await this.deps.graphIndexFor(storage).onMemoryWritten({
340
+ memoryPath: memoryRelPath,
341
+ entityRef,
342
+ content: factContent,
343
+ created: new Date().toISOString(),
344
+ threadId: threadIdForEdge,
345
+ recentInThread,
346
+ entitySiblings,
347
+ causalPredecessor,
348
+ graphCapsOverride: {
349
+ entityGraph: graphCaps.entityGraph,
350
+ timeGraph: graphCaps.timeGraph,
351
+ causalGraph: graphCaps.causalGraph,
352
+ multiGraphMemory: graphCaps.multiGraphMemory,
353
+ },
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Batch-update temporal and tag indexes after extraction (v8.1).
359
+ * Reads each persisted memory's path + frontmatter and adds them to
360
+ * state/index_time.json and state/index_tags.json.
361
+ * Fail-open: any error is logged but does not abort extraction.
362
+ */
363
+ async updateTemporalTagIndexes(
364
+ storage: StorageManager,
365
+ persistedIds: string[],
366
+ ): Promise<void> {
367
+ const caps = resolveCapabilities(this.deps.config); // #1566 Cluster C
368
+ // Build temporal/tag indexes whenever either consumer is enabled:
369
+ // - queryAwareIndexingEnabled: uses indexes for query-aware prefiltering in recall
370
+ // - parallelRetrievalEnabled: temporal agent reads index_time.json for date-range lookup
371
+ // Enabling only parallelRetrievalEnabled without queryAwareIndexingEnabled would silently
372
+ // produce an empty temporal index, leaving the temporal agent with no data to work from.
373
+ if (
374
+ !resolveIndexingCapabilities(this.deps.config).queryAwareIndexing &&
375
+ !caps.parallelRetrieval
376
+ )
377
+ return;
378
+ // Check for missing indexes BEFORE the early-return so first-time enablement
379
+ // can bootstrap the full corpus even when this extraction turn persisted nothing.
380
+ const needsFullRebuild = !indexesExist(this.deps.config.memoryDir);
381
+ if (!needsFullRebuild && persistedIds.length === 0) return;
382
+ try {
383
+ // Read the corpus once to avoid N separate full-corpus scans.
384
+ // On full rebuild with namespaces enabled, span all configured namespaces so
385
+ // memories written to other namespaces before the index existed are also captured.
386
+ const allMemories =
387
+ needsFullRebuild && resolveNamespaceCapabilities(this.deps.config).namespaces
388
+ ? await this.deps.readAllMemoriesForNamespaces(
389
+ Array.from(
390
+ new Set<string>([
391
+ this.deps.config.defaultNamespace,
392
+ this.deps.config.sharedNamespace,
393
+ ...this.deps.config.namespacePolicies.map((p) => p.name),
394
+ ]),
395
+ ),
396
+ )
397
+ : await storage.readAllMemories();
398
+
399
+ // Bootstrap: index only active (non-archived, non-superseded) memories.
400
+ // Incremental: index only the newly persisted IDs.
401
+ const pool = needsFullRebuild
402
+ ? allMemories.filter((m) => isActiveMemoryStatus(m.frontmatter.status))
403
+ : (() => {
404
+ const idSet = new Set(persistedIds);
405
+ return allMemories.filter((m) => idSet.has(m.frontmatter.id));
406
+ })();
407
+
408
+ const entries: Array<{
409
+ path: string;
410
+ createdAt: string;
411
+ tags: string[];
412
+ }> = [];
413
+ for (const mem of pool) {
414
+ if (mem.path && mem.frontmatter?.created) {
415
+ entries.push({
416
+ path: mem.path,
417
+ createdAt: mem.frontmatter.created,
418
+ tags: mem.frontmatter.tags ?? [],
419
+ });
420
+ }
421
+ }
422
+ if (needsFullRebuild) {
423
+ // Always write empty indexes on full rebuild — even when the active pool
424
+ // is empty (e.g. store contains only archived/superseded entries).
425
+ // This marks bootstrap completion so indexesExist() returns true and
426
+ // subsequent extractions skip the full-corpus scan.
427
+ clearIndexes(this.deps.config.memoryDir);
428
+ if (entries.length > 0) {
429
+ indexMemoriesBatch(this.deps.config.memoryDir, entries);
430
+ }
431
+ log.info(
432
+ `temporal-index: bootstrapped from ${entries.length} active memories`,
433
+ );
434
+ } else if (entries.length > 0) {
435
+ indexMemoriesBatch(this.deps.config.memoryDir, entries);
436
+ }
437
+ } catch (err) {
438
+ log.debug(`temporal-index update failed (non-fatal): ${err}`);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Issue #373 — nearest-neighbor lookup for the write-time semantic dedup
444
+ * guard. Returns the top-K embedding hits against the currently indexed
445
+ * memories, or an empty array when the embedding backend is unavailable.
446
+ * Intentionally does NOT throw; `decideSemanticDedup` treats both "empty"
447
+ * and "error" outcomes as fail-open (keep the candidate).
448
+ *
449
+ * PR #399 P1 fix: when namespaces are enabled the lookup must be scoped
450
+ * to the SAME namespace as the fact being written. Otherwise a
451
+ * high-similarity memory from another namespace can suppress a write in
452
+ * the target namespace — cross-tenant data loss. Callers pass the target
453
+ * storage so we can translate its root directory into the correct index
454
+ * path prefix (and, for the legacy default-namespace layout at
455
+ * `memoryDir` root, an exclusion list for `namespaces/*`).
456
+ */
457
+ async semanticDedupLookup(
458
+ content: string,
459
+ limit: number,
460
+ targetStorage: StorageManager,
461
+ ): Promise<SemanticDedupHit[]> {
462
+ // Round 6 fix (Finding 3): backend-unavailable conditions must THROW so
463
+ // that `decideSemanticDedup`'s catch block can return
464
+ // reason="backend_unavailable". Previously all error/unavailable paths
465
+ // returned [] — causing decideSemanticDedup to always report
466
+ // reason="no_candidates" even when the provider was actually down.
467
+ //
468
+ // Contract after this fix:
469
+ // • embeddingFallbackEnabled=false → throw (feature not configured;
470
+ // caller treats this as backend_unavailable and fails open).
471
+ // • isAvailable() returns false → throw (provider is reachable but
472
+ // reports itself unavailable; distinct from empty index).
473
+ // • search() throws → re-throw (network/provider error).
474
+ // • search() returns [] → return [] (empty index, not a
475
+ // backend failure; decideSemanticDedup reports no_candidates).
476
+ if (!resolveMemoryLifecycleCapabilities(this.deps.config).embeddingFallback) {
477
+ throw new Error("semantic dedup: embedding backend not configured");
478
+ }
479
+ if (!(await this.deps.embeddingFallback.isAvailable())) {
480
+ log.debug("semantic dedup: embedding backend unavailable, skipping");
481
+ throw new Error("semantic dedup: embedding backend unavailable");
482
+ }
483
+ // search() may throw — let it propagate so decideSemanticDedup catches it
484
+ // and returns reason="backend_unavailable". Pass throwOnTimeout:true so
485
+ // EmbeddingTimeoutError is re-thrown here (Round 10 fix, Ui1J+Ui1L: the
486
+ // recall-path caller searchEmbeddingFallback does NOT pass this flag,
487
+ // keeping its fail-open [] contract on timeout).
488
+ const scope = this.deps.semanticDedupScopeFor(targetStorage);
489
+ const hits = await this.deps.embeddingFallback.search(content, limit, { ...scope, throwOnTimeout: true });
490
+ if (!Array.isArray(hits) || hits.length === 0) return [];
491
+ return hits.map((hit) => ({
492
+ id: hit.id,
493
+ score: hit.score,
494
+ path: hit.path,
495
+ }));
496
+ }
497
+ }