@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.
- package/dist/access-boundary.d.ts +2 -2
- package/dist/access-cli.js +1 -1
- package/dist/access-http.d.ts +2 -2
- package/dist/access-mcp.d.ts +2 -2
- package/dist/access-operations.d.ts +2 -2
- package/dist/{access-service-CH1aqqb1.d.ts → access-service-5-EVpt3v.d.ts} +1 -1
- package/dist/access-service.d.ts +2 -2
- package/dist/access-surface-catalog.d.ts +2 -2
- package/dist/bootstrap.d.ts +1 -1
- package/dist/{chunk-ZWGXZ7ID.js → chunk-PNUOQYRJ.js} +560 -457
- package/dist/chunk-PNUOQYRJ.js.map +1 -0
- package/dist/{cli-CE5olss2.d.ts → cli-DuWWjaKT.d.ts} +2 -2
- package/dist/cli.d.ts +3 -3
- package/dist/explicit-capture.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +2 -2
- package/dist/{orchestrator-CIOqdNzF.d.ts → orchestrator-CZI6hO_R.d.ts} +7 -56
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +1 -1
- package/package.json +2 -2
- package/src/orchestration/persistence-index.ts +497 -0
- package/src/orchestrator.ts +69 -365
- package/dist/chunk-ZWGXZ7ID.js.map +0 -1
|
@@ -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
|
+
}
|