recallnest 2.5.4 → 2.6.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 (81) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +34 -9
  3. package/README_CN.md +37 -12
  4. package/package.json +4 -4
  5. package/src/__tests__/access-tracker-novelty.test.ts +66 -0
  6. package/src/__tests__/api-server-security.test.ts +102 -0
  7. package/src/__tests__/belief-history.test.ts +220 -0
  8. package/src/__tests__/capture-engine.test.ts +93 -3
  9. package/src/__tests__/category-min-scores.test.ts +83 -0
  10. package/src/__tests__/cluster-consolidation.test.ts +167 -4
  11. package/src/__tests__/conflict-store.test.ts +46 -2
  12. package/src/__tests__/consolidation-engine.test.ts +97 -0
  13. package/src/__tests__/contracts.test.ts +46 -0
  14. package/src/__tests__/decay-exemption.test.ts +45 -0
  15. package/src/__tests__/dream-pipeline.test.ts +464 -5
  16. package/src/__tests__/freshness.test.ts +186 -0
  17. package/src/__tests__/hotness-blend.test.ts +207 -0
  18. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  19. package/src/__tests__/maintenance-locks.test.ts +15 -0
  20. package/src/__tests__/memory-boundaries.test.ts +32 -0
  21. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  22. package/src/__tests__/pii-redact.test.ts +19 -0
  23. package/src/__tests__/pivot-apply.test.ts +373 -0
  24. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  25. package/src/__tests__/pivot-distill.test.ts +1178 -0
  26. package/src/__tests__/preference-matcher.test.ts +54 -2
  27. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  28. package/src/__tests__/recall-gate.test.ts +266 -0
  29. package/src/__tests__/resolve-db-path.test.ts +130 -0
  30. package/src/__tests__/resolve-tier.test.ts +64 -0
  31. package/src/__tests__/retrieval-eval.test.ts +163 -0
  32. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  33. package/src/__tests__/skill-promotion.test.ts +53 -0
  34. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  35. package/src/__tests__/store-read-consistency.test.ts +122 -0
  36. package/src/__tests__/store-schema-migration.test.ts +87 -0
  37. package/src/__tests__/store-upsert.test.ts +17 -0
  38. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  39. package/src/__tests__/ui-server-security.test.ts +93 -0
  40. package/src/access-tracker.ts +53 -3
  41. package/src/adaptive-retrieval.ts +11 -0
  42. package/src/api-server.ts +9 -2
  43. package/src/auto-recall.ts +61 -1
  44. package/src/belief-history.ts +164 -0
  45. package/src/capture-engine.ts +88 -11
  46. package/src/cli.ts +109 -14
  47. package/src/conflict-engine.ts +24 -3
  48. package/src/consolidation-engine.ts +146 -12
  49. package/src/context-composer-stable-selection.ts +7 -1
  50. package/src/data-checkup.ts +1 -1
  51. package/src/decay-engine.ts +59 -7
  52. package/src/doctor.ts +4 -2
  53. package/src/dream-pipeline.ts +359 -11
  54. package/src/env-config.ts +41 -0
  55. package/src/eval.ts +154 -11
  56. package/src/freshness.ts +192 -0
  57. package/src/http-request-policy.ts +148 -0
  58. package/src/ingest.ts +277 -2
  59. package/src/kg-extractor.ts +7 -1
  60. package/src/llm-client.ts +106 -11
  61. package/src/mcp-server.ts +2 -1
  62. package/src/mcp-tools-advanced.ts +13 -0
  63. package/src/mcp-tools-core.ts +5 -1
  64. package/src/memory-boundaries.ts +23 -2
  65. package/src/memory-health-rebalance.ts +4 -0
  66. package/src/memory-output.ts +16 -2
  67. package/src/memory-store-port.ts +3 -0
  68. package/src/noise-prototype-bank.ts +408 -0
  69. package/src/pii-detector.ts +52 -2
  70. package/src/pivot-apply.ts +399 -0
  71. package/src/preference-matcher.ts +37 -3
  72. package/src/recall-gate.ts +305 -0
  73. package/src/retrieval-profiles.ts +46 -4
  74. package/src/retriever.ts +95 -18
  75. package/src/retroactive-boost.ts +6 -2
  76. package/src/runtime-config.ts +70 -5
  77. package/src/skill-promotion.ts +58 -2
  78. package/src/store.ts +54 -19
  79. package/src/term-registry.ts +35 -0
  80. package/src/ui-server.ts +7 -0
  81. package/src/version.ts +19 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Pivot Apply — dedicated structured write channel for reviewed pivot candidates.
3
+ *
4
+ * Purpose: apply LLM-distilled pivot candidates (reportId-keyed, reviewed via
5
+ * allowlist) into scope `memory:pivot` with deterministic dispositions.
6
+ *
7
+ * Why not the normal store_memory path (mutual-review P0, 2026-08-04):
8
+ * - persistMemory routes preferences through matchPreference (LLM semantic
9
+ * merge) → polymorphic dispositions the apply ledger cannot predict;
10
+ * - A-2 supersede detection calls the LLM per write (re-judging candidates is
11
+ * out of contract for apply);
12
+ * - default source=manual mislabels distilled content as 0.9/direct confidence;
13
+ * - kind/anchor/evidence/reportId/candidateHash/batchId are dropped;
14
+ * - the implicit preference dual-write (LME-1) is fire-and-forget, so its id
15
+ * can never reach the ledger.
16
+ *
17
+ * This channel therefore:
18
+ * - keeps: PII scan+redact (trust boundary), rule-based admission (minus rate
19
+ * limiting — bulk writes always trip it and it is not a quality signal),
20
+ * canonicalKey supersede semantics via writeDurableEntry, audit logging,
21
+ * synchronous post-write verification;
22
+ * - drops: every LLM call, F2 interference marking (must not silently mutate
23
+ * existing memories), async KG extraction, and the implicit preference
24
+ * dual-write (apply's contract is to write exactly the allowlisted entries
25
+ * and nothing else — a derived preference is not on the allowlist).
26
+ */
27
+
28
+ import { z } from "zod";
29
+
30
+ import { checkAdmission } from "./admission-control.js";
31
+ import {
32
+ buildStructuredMetadata,
33
+ writeDurableEntry,
34
+ type PersistMemoryDeps,
35
+ } from "./capture-engine.js";
36
+ import { assignDefaultConfidence } from "./confidence-tracker.js";
37
+ import { detectLang, tokenizeFts } from "./language-hook.js";
38
+ import { scanForPII, redactSecrets } from "./pii-detector.js";
39
+
40
+ export const PIVOT_APPLY_SOURCE = "session_distill";
41
+ export const PIVOT_APPLY_CAPTURE = "pivot_apply_v1";
42
+ export const PIVOT_SCOPE = "memory:pivot";
43
+
44
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
45
+
46
+ export const PivotKindSchema = z.enum([
47
+ "judgment_shift",
48
+ "decision",
49
+ "preference_rule",
50
+ "case",
51
+ ]);
52
+ export type PivotKind = z.infer<typeof PivotKindSchema>;
53
+
54
+ /** The only kind→category pairs that exist in the corpus (verified over all 8,971 candidates). */
55
+ const KIND_CATEGORY: Record<PivotKind, "cases" | "events" | "preferences"> = {
56
+ case: "cases",
57
+ decision: "events",
58
+ judgment_shift: "events",
59
+ preference_rule: "preferences",
60
+ };
61
+
62
+ export const PivotCandidateInputSchema = z
63
+ .object({
64
+ kind: PivotKindSchema,
65
+ text: z.string().min(10).max(4000),
66
+ anchor: z.string().min(1).max(2000),
67
+ canonicalKey: z.string().min(1).max(120),
68
+ evidence: z.array(z.string().min(1).max(4000)).max(16),
69
+ proposedScope: z.literal(PIVOT_SCOPE),
70
+ proposedCategory: z.enum(["cases", "events", "preferences"]),
71
+ // candidate tags max out at 4 in the corpus; the channel appends 2 more and
72
+ // buildStructuredMetadata stores tags verbatim, so cap input at 6 to stay
73
+ // within the schema-wide 8-tag budget.
74
+ tags: z.array(z.string().min(1).max(40)).min(1).max(6),
75
+ sessionId: z.string().min(8).max(200),
76
+ reportId: z.string().regex(SHA256_HEX, "reportId must be 64-char sha256 hex"),
77
+ candidateHash: z.string().regex(SHA256_HEX, "candidateHash must be 64-char sha256 hex"),
78
+ batchId: z.string().min(1).max(64),
79
+ importance: z.number().min(0).max(1),
80
+ })
81
+ .refine((c) => KIND_CATEGORY[c.kind] === c.proposedCategory, {
82
+ message: "proposedCategory does not match kind (corpus-verified mapping violated)",
83
+ });
84
+ export type PivotCandidateInput = z.infer<typeof PivotCandidateInputSchema>;
85
+
86
+ export type PivotApplyDisposition =
87
+ | "stored"
88
+ | "updated"
89
+ | "deduped"
90
+ | "promoted"
91
+ | "conflict"
92
+ | "rejected";
93
+
94
+ /**
95
+ * The stock embedder only retries connection-class errors; HTTP 429/5xx throw
96
+ * straight through (embedder.ts isTransientEmbeddingError). A bulk apply makes
97
+ * thousands of calls, so rate-limit blips must not void a whole batch — retry
98
+ * with backoff here, and only here (single-write callers keep stock behavior).
99
+ */
100
+ async function embedWithRetry(
101
+ embedder: PersistMemoryDeps["embedder"],
102
+ text: string,
103
+ delaysMs: readonly number[] = [1000, 5000, 15000],
104
+ ): Promise<number[]> {
105
+ let lastErr: unknown;
106
+ for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
107
+ try {
108
+ return await embedder.embedPassage(text);
109
+ } catch (err) {
110
+ lastErr = err;
111
+ const msg = String((err as Error)?.message ?? err);
112
+ const retriable = /\b(429|500|502|503|504)\b|rate.?limit|overloaded/i.test(msg);
113
+ if (!retriable || attempt === delaysMs.length) throw err;
114
+ await new Promise((r) => setTimeout(r, delaysMs[attempt]));
115
+ }
116
+ }
117
+ throw lastErr;
118
+ }
119
+
120
+ export interface PivotApplyOutcome {
121
+ candidateHash: string;
122
+ batchId: string;
123
+ /** null when the write never reached the store (admission rejection). */
124
+ memoryId: string | null;
125
+ disposition: PivotApplyDisposition;
126
+ conflictId?: string;
127
+ rejectionReason?: string;
128
+ supersededOldId?: string;
129
+ piiRedactedCount: number;
130
+ canonicalKey: string;
131
+ category: "cases" | "events" | "preferences";
132
+ verified: boolean;
133
+ verifyIssues: string[];
134
+ }
135
+
136
+ export interface PivotBatchReport {
137
+ batchId: string;
138
+ total: number;
139
+ written: number;
140
+ halted: boolean;
141
+ haltReason?: string;
142
+ outcomes: PivotApplyOutcome[];
143
+ }
144
+
145
+ /**
146
+ * Persist one reviewed pivot candidate. Deterministic: no LLM calls, no writes
147
+ * outside the candidate itself (plus the audit log line).
148
+ */
149
+ export async function persistPivotCandidate(
150
+ deps: PersistMemoryDeps,
151
+ rawInput: unknown,
152
+ ): Promise<PivotApplyOutcome> {
153
+ const input = PivotCandidateInputSchema.parse(rawInput);
154
+
155
+ // F-3b ordering (same as persistMemory): scan first so piiWarning reflects
156
+ // the raw text, then redact before the text reaches embedding/storage.
157
+ const piiScan = scanForPII(input.text);
158
+ const redaction = redactSecrets(input.text);
159
+ const text = redaction.redacted > 0 ? redaction.text : input.text;
160
+
161
+ // Rule-based admission only. No rateLimiter on purpose: a bulk apply always
162
+ // exceeds per-scope write rates, which says nothing about entry quality.
163
+ const admission = checkAdmission(text, input.importance, PIVOT_SCOPE, undefined, deps.admissionConfig);
164
+ if (admission.verdict === "rejected") {
165
+ try {
166
+ deps.auditLogger?.log({
167
+ operation: "reject",
168
+ scope: PIVOT_SCOPE,
169
+ actor: PIVOT_APPLY_SOURCE,
170
+ details: `pivot-apply ${input.kind} ${input.candidateHash.slice(0, 12)}: ${admission.reason ?? "unknown"}`,
171
+ });
172
+ } catch {
173
+ // Audit must never block the outcome report.
174
+ }
175
+ return {
176
+ candidateHash: input.candidateHash,
177
+ batchId: input.batchId,
178
+ memoryId: null,
179
+ disposition: "rejected",
180
+ rejectionReason: admission.reason,
181
+ piiRedactedCount: redaction.redacted,
182
+ canonicalKey: input.canonicalKey,
183
+ category: input.proposedCategory,
184
+ verified: false,
185
+ verifyIssues: [],
186
+ };
187
+ }
188
+
189
+ const vector = await embedWithRetry(deps.embedder, text);
190
+ // The store layer never validates vector shape (only importEntry does), so an
191
+ // empty/short vector would land silently and poison retrieval — assert here.
192
+ if (!Array.isArray(vector) || vector.length === 0) {
193
+ throw new Error(`embedding returned an empty vector for candidate ${input.candidateHash.slice(0, 12)}`);
194
+ }
195
+ const language = detectLang(text);
196
+ const fts_text = tokenizeFts(text, language);
197
+
198
+ let metadata = buildStructuredMetadata({
199
+ source: PIVOT_APPLY_SOURCE,
200
+ tags: [...input.tags, "pivot-apply", `batch:${input.batchId}`],
201
+ capture: PIVOT_APPLY_CAPTURE,
202
+ category: input.proposedCategory,
203
+ canonicalKey: input.canonicalKey,
204
+ extra: {
205
+ anchor: input.anchor,
206
+ pivotApply: {
207
+ kind: input.kind,
208
+ evidence: input.evidence,
209
+ sessionId: input.sessionId,
210
+ reportId: input.reportId,
211
+ candidateHash: input.candidateHash,
212
+ batchId: input.batchId,
213
+ },
214
+ },
215
+ });
216
+ {
217
+ // Same post-build patch pattern persistMemory uses for confidence/PII.
218
+ const parsed = JSON.parse(metadata) as Record<string, unknown>;
219
+ parsed.confidence = assignDefaultConfidence(PIVOT_APPLY_SOURCE);
220
+ if (piiScan.hasPII) {
221
+ parsed.piiWarning = {
222
+ summary: piiScan.summary,
223
+ detections: piiScan.detections.length,
224
+ severity: piiScan.detections.some((d) => d.severity === "high") ? "high" : "medium",
225
+ ...(redaction.redacted > 0 ? { redacted: redaction.redacted } : {}),
226
+ };
227
+ }
228
+ metadata = JSON.stringify(parsed);
229
+ }
230
+
231
+ const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
232
+ text,
233
+ vector,
234
+ category: input.proposedCategory,
235
+ scope: PIVOT_SCOPE,
236
+ importance: input.importance,
237
+ metadata,
238
+ canonicalKey: input.canonicalKey,
239
+ source: PIVOT_APPLY_SOURCE,
240
+ language,
241
+ fts_text,
242
+ });
243
+
244
+ try {
245
+ deps.auditLogger?.log({
246
+ operation: "store",
247
+ scope: PIVOT_SCOPE,
248
+ memoryId: entry.id,
249
+ actor: PIVOT_APPLY_SOURCE,
250
+ details: `pivot-apply ${input.kind} ${input.candidateHash.slice(0, 12)} → ${disposition}`,
251
+ });
252
+ } catch {
253
+ // Audit must never block the outcome report.
254
+ }
255
+
256
+ // Post-write verification, synchronous on purpose: a bulk apply needs the
257
+ // verdict inside the ledger row, not on a console later. Deliberately NOT
258
+ // verifyWrite(): its checkEntry expects scope/importance inside metadata,
259
+ // which buildStructuredMetadata-shaped rows never carry (a pre-existing
260
+ // mismatch its async caller silently swallows). This check asserts the claim
261
+ // that matters instead: the row at entry.id holds THIS candidate's content.
262
+ let verified = true;
263
+ let verifyIssues: string[] = [];
264
+ if ((disposition === "stored" || disposition === "updated") && deps.store.get) {
265
+ verifyIssues = await verifyPivotRow(deps.store.get.bind(deps.store), entry.id, {
266
+ candidateHash: input.candidateHash,
267
+ text,
268
+ });
269
+ verified = verifyIssues.length === 0;
270
+ }
271
+
272
+ return {
273
+ candidateHash: input.candidateHash,
274
+ batchId: input.batchId,
275
+ memoryId: entry.id,
276
+ disposition,
277
+ ...(conflictId ? { conflictId } : {}),
278
+ piiRedactedCount: redaction.redacted,
279
+ canonicalKey: input.canonicalKey,
280
+ category: input.proposedCategory,
281
+ verified,
282
+ verifyIssues,
283
+ };
284
+ }
285
+
286
+ function normalizeForCompare(text: string): string {
287
+ return text.replace(/\s+/g, " ").trim();
288
+ }
289
+
290
+ /** Read the row back and assert it is this candidate's write, not merely a well-formed row. */
291
+ async function verifyPivotRow(
292
+ get: (id: string) => Promise<{ text: string; scope: string; metadata: string } | null>,
293
+ id: string,
294
+ expected: { candidateHash: string; text: string },
295
+ ): Promise<string[]> {
296
+ const issues: string[] = [];
297
+ let row: { text: string; scope: string; metadata: string } | null = null;
298
+ try {
299
+ row = await get(id);
300
+ } catch (err) {
301
+ return [`readback threw: ${(err as Error).message}`];
302
+ }
303
+ if (!row) return ["row missing on readback"];
304
+ if (row.scope !== PIVOT_SCOPE) issues.push(`scope is "${row.scope}", expected "${PIVOT_SCOPE}"`);
305
+ if (normalizeForCompare(row.text) !== normalizeForCompare(expected.text)) {
306
+ issues.push("text on readback differs from written text");
307
+ }
308
+ try {
309
+ const meta = JSON.parse(row.metadata || "{}") as Record<string, unknown>;
310
+ const pivotApply = meta.pivotApply as Record<string, unknown> | undefined;
311
+ if (pivotApply?.candidateHash !== expected.candidateHash) {
312
+ issues.push("pivotApply.candidateHash on readback does not match this candidate");
313
+ }
314
+ } catch {
315
+ issues.push("metadata unparseable on readback");
316
+ }
317
+ return issues;
318
+ }
319
+
320
+ export interface PivotBatchOptions {
321
+ /** Dispositions that do NOT halt the batch. Default: only "stored". */
322
+ expectedDispositions?: readonly PivotApplyDisposition[];
323
+ }
324
+
325
+ /**
326
+ * Persist a physical batch sequentially, fail-closed.
327
+ *
328
+ * Pre-screening guarantees the incoming batch has no key collisions with the
329
+ * live pool (R5) and no identical-text duplicates (R3 folding), so every
330
+ * disposition other than "stored" is unexpected by default and halts the batch
331
+ * immediately. Rows already written before the halt stay in the store — the
332
+ * caller reconciles via the returned outcomes (ledger) and the table snapshot
333
+ * taken before the batch.
334
+ */
335
+ export async function persistPivotBatch(
336
+ deps: PersistMemoryDeps,
337
+ batchId: string,
338
+ rawInputs: readonly unknown[],
339
+ options?: PivotBatchOptions,
340
+ ): Promise<PivotBatchReport> {
341
+ const expected = new Set<PivotApplyDisposition>(options?.expectedDispositions ?? ["stored"]);
342
+ const outcomes: PivotApplyOutcome[] = [];
343
+ let written = 0;
344
+
345
+ for (const raw of rawInputs) {
346
+ // Halt before the write when the row belongs to a different batch: the
347
+ // ledger keys every side effect by batchId, so a mixed batch would corrupt
348
+ // snapshot/rollback bookkeeping.
349
+ const claimed =
350
+ typeof raw === "object" && raw !== null && "batchId" in raw
351
+ ? String((raw as Record<string, unknown>).batchId)
352
+ : "(missing)";
353
+ if (claimed !== batchId) {
354
+ return {
355
+ batchId,
356
+ total: rawInputs.length,
357
+ written,
358
+ halted: true,
359
+ haltReason: `candidate claims batchId "${claimed}" but batch is "${batchId}"`,
360
+ outcomes,
361
+ };
362
+ }
363
+
364
+ const outcome = await persistPivotCandidate(deps, raw);
365
+ outcomes.push(outcome);
366
+ if (outcome.memoryId !== null && (outcome.disposition === "stored" || outcome.disposition === "updated")) {
367
+ written += 1;
368
+ }
369
+
370
+ if (!expected.has(outcome.disposition)) {
371
+ return {
372
+ batchId,
373
+ total: rawInputs.length,
374
+ written,
375
+ halted: true,
376
+ haltReason: `unexpected disposition "${outcome.disposition}" for candidate ${outcome.candidateHash.slice(0, 12)}`,
377
+ outcomes,
378
+ };
379
+ }
380
+ if (!outcome.verified && (outcome.disposition === "stored" || outcome.disposition === "updated")) {
381
+ return {
382
+ batchId,
383
+ total: rawInputs.length,
384
+ written,
385
+ halted: true,
386
+ haltReason: `post-write verification failed for ${outcome.candidateHash.slice(0, 12)}: ${outcome.verifyIssues.join("; ")}`,
387
+ outcomes,
388
+ };
389
+ }
390
+ }
391
+
392
+ return {
393
+ batchId,
394
+ total: rawInputs.length,
395
+ written,
396
+ halted: false,
397
+ outcomes,
398
+ };
399
+ }
@@ -15,6 +15,9 @@ import type { MemoryStore, MemoryEntry } from "./store.js";
15
15
  import type { Embedder } from "./embedder.js";
16
16
  import type { LLMClient } from "./llm-client.js";
17
17
  import { logInfo } from "./stderr-log.js";
18
+ import { isActiveMemory, parseEvolution } from "./memory-evolution.js";
19
+ import { archiveBeliefVersion, buildSupersedingBeliefMetadata } from "./belief-history.js";
20
+ import { detectLang, tokenizeFts } from "./language-hook.js";
18
21
 
19
22
  // ============================================================================
20
23
  // Configuration
@@ -77,8 +80,13 @@ export async function matchPreference(
77
80
  [scope],
78
81
  );
79
82
 
80
- // Filter to only preferences category
81
- const prefMatches = candidates.filter(c => c.entry.category === "preferences");
83
+ // Filter to only preferences category. store.vectorSearch() filters by scope only, so
84
+ // superseded belief-history rows come back here too — matching against one would either
85
+ // silently drop the incoming preference as a duplicate of a belief no longer held, or
86
+ // merge new text into an archived version.
87
+ const prefMatches = candidates.filter(
88
+ c => c.entry.category === "preferences" && isActiveMemory(c.entry.metadata),
89
+ );
82
90
 
83
91
  if (prefMatches.length === 0) {
84
92
  return { action: "create", reason: "no-similar-preferences" };
@@ -132,6 +140,7 @@ export async function applyPreferenceMatch(
132
140
  result: PreferenceMatchResult,
133
141
  store: MemoryStore,
134
142
  scope: string,
143
+ deps: { embedder: Pick<Embedder, "embedPassage"> },
135
144
  ): Promise<{ handled: boolean; entry?: MemoryEntry }> {
136
145
  if (result.action === "skip") {
137
146
  logInfo(`[INFO] Preference skipped (duplicate): ${result.reason}`);
@@ -139,9 +148,34 @@ export async function applyPreferenceMatch(
139
148
  }
140
149
 
141
150
  if (result.action === "merge" && result.mergeTargetId && result.mergedText) {
151
+ const existing = await store.getById(result.mergeTargetId);
152
+ if (!existing) return { handled: false };
153
+
154
+ // Merging rewrites the target's text, which is a belief change like any other —
155
+ // archive the version being replaced before overwriting it.
156
+ const now = Date.now();
157
+ const previousEvo = parseEvolution(existing.metadata, existing.timestamp);
158
+ const { historyId } = await archiveBeliefVersion({ store, embedder: deps.embedder }, existing, { now });
159
+
160
+ // The old code updated `text` alone, leaving vector and fts_text describing the
161
+ // pre-merge wording — so a merged preference stayed unfindable by its own new text.
162
+ const language = detectLang(result.mergedText);
142
163
  const updated = await store.update(
143
164
  result.mergeTargetId,
144
- { text: result.mergedText },
165
+ {
166
+ text: result.mergedText,
167
+ vector: await deps.embedder.embedPassage(result.mergedText),
168
+ metadata: buildSupersedingBeliefMetadata(
169
+ existing.metadata,
170
+ previousEvo,
171
+ historyId,
172
+ now,
173
+ `preference-merge:${result.reason}`,
174
+ ),
175
+ timestamp: now,
176
+ language,
177
+ fts_text: tokenizeFts(result.mergedText, language),
178
+ },
145
179
  [scope],
146
180
  );
147
181
  if (updated) {