recallnest 2.5.4 → 2.6.1

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 +6 -5
  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,93 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ enforceLocalHttpRequestPolicy,
5
+ LOCAL_HTTP_HOSTNAME,
6
+ LOCAL_JSON_BODY_LIMIT_BYTES,
7
+ } from "../http-request-policy.js";
8
+
9
+ function uiRequest({
10
+ method = "GET",
11
+ host = "127.0.0.1:4317",
12
+ headers = {},
13
+ body,
14
+ }: {
15
+ method?: string;
16
+ host?: string;
17
+ headers?: Record<string, string>;
18
+ body?: string;
19
+ } = {}): Request {
20
+ return new Request("http://127.0.0.1:4317/api/search", {
21
+ method,
22
+ headers: { host, ...headers },
23
+ body,
24
+ });
25
+ }
26
+
27
+ describe("RecallNest UI local HTTP policy", () => {
28
+ test("binds the UI to the IPv4 loopback interface", () => {
29
+ expect(LOCAL_HTTP_HOSTNAME).toBe("127.0.0.1");
30
+ });
31
+
32
+ test("allows same-origin browser requests", async () => {
33
+ const result = await enforceLocalHttpRequestPolicy(uiRequest({
34
+ method: "POST",
35
+ headers: {
36
+ "content-type": "application/json; charset=utf-8",
37
+ origin: "http://127.0.0.1:4317",
38
+ "sec-fetch-site": "same-origin",
39
+ },
40
+ body: JSON.stringify({ query: "local" }),
41
+ }));
42
+
43
+ expect(result).toEqual({ allowed: true });
44
+ });
45
+
46
+ test("rejects a non-local Host header", async () => {
47
+ const result = await enforceLocalHttpRequestPolicy(uiRequest({
48
+ host: "attacker.example",
49
+ }));
50
+
51
+ expect(result).toEqual({ allowed: false, status: 403, message: "Host is not allowed." });
52
+ });
53
+
54
+ test("rejects cross-origin and cross-site browser requests", async () => {
55
+ const wrongOrigin = await enforceLocalHttpRequestPolicy(uiRequest({
56
+ headers: { origin: "https://attacker.example" },
57
+ }));
58
+ const crossSite = await enforceLocalHttpRequestPolicy(uiRequest({
59
+ headers: { "sec-fetch-site": "cross-site" },
60
+ }));
61
+
62
+ expect(wrongOrigin).toEqual({ allowed: false, status: 403, message: "Origin is not allowed." });
63
+ expect(crossSite).toEqual({ allowed: false, status: 403, message: "Cross-site requests are not allowed." });
64
+ });
65
+
66
+ test("rejects non-JSON POST bodies", async () => {
67
+ const result = await enforceLocalHttpRequestPolicy(uiRequest({
68
+ method: "POST",
69
+ headers: { "content-type": "text/plain" },
70
+ body: JSON.stringify({ query: "local" }),
71
+ }));
72
+
73
+ expect(result).toEqual({
74
+ allowed: false,
75
+ status: 415,
76
+ message: "Content-Type must be application/json.",
77
+ });
78
+ });
79
+
80
+ test("rejects streamed JSON bodies over the limit", async () => {
81
+ const result = await enforceLocalHttpRequestPolicy(uiRequest({
82
+ method: "POST",
83
+ headers: { "content-type": "application/json" },
84
+ body: JSON.stringify({ query: "x".repeat(LOCAL_JSON_BODY_LIMIT_BYTES) }),
85
+ }));
86
+
87
+ expect(result).toEqual({
88
+ allowed: false,
89
+ status: 413,
90
+ message: `Request body exceeds ${LOCAL_JSON_BODY_LIMIT_BYTES} bytes.`,
91
+ });
92
+ });
93
+ });
@@ -38,10 +38,18 @@ export const DEFAULT_ACCESS_TRACKER_CONFIG: AccessTrackerConfig = {
38
38
  reinforcementFactor: 0.5,
39
39
  maxMultiplier: 3.0,
40
40
  accessFreshnessHalfLifeDays: 30,
41
- noveltyThreshold: 0.35,
41
+ // 0 = novelty gate off. 生产实测(2026-07-23):0.35 要求命中的 cosine ≤0.65 才打点,
42
+ // 真实相关命中几乎都 >0.65,导致 accessCount 在生产库全空——"最被用到"的记忆
43
+ // 恰好全被排除,方向与"用进废退"相反。防刷由 cooldown 承担;要恢复旧行为显式传 0.35。
44
+ noveltyThreshold: 0,
42
45
  cooldownMs: 300_000, // 5 minutes
43
46
  };
44
47
 
48
+ /** readerIds 在 metadata 里的存储上限。distinctReaderCount 到达上限后饱和不再涨。 */
49
+ // simplified: capped array + saturating count instead of Artel's exact memory_reads
50
+ // ledger table — promotion thresholds are ≤4 distinct readers, so saturation at 8 is harmless.
51
+ export const READER_ID_CAP = 8;
52
+
45
53
  // ============================================================================
46
54
  // Core
47
55
  // ============================================================================
@@ -52,11 +60,36 @@ export class AccessTracker {
52
60
  private flushPromise: Promise<void> | null = null;
53
61
  /** Cooldown map: entryId → last reinforced timestamp */
54
62
  private cooldownMap = new Map<string, number>();
63
+ /** Distinct-reader identity: one stdio MCP server process ≈ one CC session.
64
+ * Used to maintain readerIds / distinctReaderCount on flush (Artel-style read fan-out). */
65
+ readonly readerId: string;
66
+ private exitFlushRegistered = false;
55
67
 
56
68
  constructor(
57
69
  private store: MemoryStore,
58
70
  private config: AccessTrackerConfig = DEFAULT_ACCESS_TRACKER_CONFIG,
59
- ) {}
71
+ readerId?: string,
72
+ ) {
73
+ this.readerId = readerId ?? `r-${crypto.randomUUID().slice(0, 8)}`;
74
+ }
75
+
76
+ /**
77
+ * Best-effort flush on normal process exit (stdin close → clean shutdown).
78
+ * Covers the "retrieval happened <flushIntervalMs before exit" window where the
79
+ * debounce timer would otherwise drop pending deltas. Opt-in so tests creating
80
+ * many trackers don't leak process listeners. Not wired to SIGTERM on purpose —
81
+ * an async handler there isn't guaranteed to complete and we don't want to own
82
+ * the server's exit semantics.
83
+ */
84
+ registerExitFlush(): void {
85
+ if (this.exitFlushRegistered) return;
86
+ this.exitFlushRegistered = true;
87
+ process.once("beforeExit", () => {
88
+ this.flush().catch(err => {
89
+ logWarn("AccessTracker exit flush failed:", err);
90
+ });
91
+ });
92
+ }
60
93
 
61
94
  /**
62
95
  * Determine whether a retrieval result should trigger access reinforcement.
@@ -173,7 +206,7 @@ export class AccessTracker {
173
206
  const importance = entry.importance ?? 0.6;
174
207
  const lastAccessedAt = now;
175
208
 
176
- const currentTier = resolveTier(entry.metadata);
209
+ const currentTier = resolveTier(entry.metadata, importance);
177
210
  const newTier = evaluateTierChange(
178
211
  currentTier,
179
212
  newCount,
@@ -184,10 +217,27 @@ export class AccessTracker {
184
217
  ? { from: currentTier, to: newTier, newCount }
185
218
  : null;
186
219
 
220
+ // Distinct-reader bookkeeping (Artel read fan-out, lightweight form):
221
+ // dedup by readerId in a capped array; count saturates at cap (under-
222
+ // counting is safer than over-counting for promotion gating).
223
+ const rawReaders = Array.isArray(meta.readerIds)
224
+ ? (meta.readerIds as unknown[]).filter((x): x is string => typeof x === "string")
225
+ : [];
226
+ let readerIds = rawReaders;
227
+ let distinctReaderCount = typeof meta.distinctReaderCount === "number"
228
+ ? meta.distinctReaderCount
229
+ : rawReaders.length;
230
+ if (!rawReaders.includes(this.readerId) && rawReaders.length < READER_ID_CAP) {
231
+ readerIds = [...rawReaders, this.readerId];
232
+ distinctReaderCount = readerIds.length;
233
+ }
234
+
187
235
  return {
188
236
  ...meta,
189
237
  accessCount: newCount,
190
238
  lastAccessedAt,
239
+ readerIds,
240
+ distinctReaderCount,
191
241
  ...(tierChange ? { tier: tierChange.to } : {}),
192
242
  };
193
243
  });
@@ -55,8 +55,19 @@ export function shouldSkipRetrieval(query: string, minLength?: number): boolean
55
55
 
56
56
  const effectiveMinLength = minLength ?? (hasCJK ? 2 : 15);
57
57
 
58
+ // 【本地分叉点 · 2026-08-08 · 勿在同步上游时覆盖回去】
59
+ // 单 token(不含空格)= 实体查找意图,与问号一样豁免长度闸。
60
+ // 上游的 ASCII 阈值 15 是按「英文自然语句」定的(挡 "hello there" 这类闲聊),
61
+ // 但项目名/工具名几乎都短于 15:cobbler=7 / slidesmith=10 / hippo-wiki=10 / recallnest=10,
62
+ // 于是「甩一个名字过去」这种最自然的查法一律返回空——库里明明有。
63
+ // 红绿实测:`cobbler` → No results;`cobbler?` → 100% 命中 12 条,只差一个问号豁免。
64
+ // 中文不受影响(阈值 2),所以该缺陷只在英文实体名上暴露,一直没被发现。
65
+ // 安全性:本条排在 SKIP_PATTERNS 之后,hi / ok / ping / test / ls / git 等单词仍被挡住。
66
+ const isSingleToken = !/\s/.test(trimmed);
67
+
58
68
  // Short non-question messages are skipped; questions (? ?) are always worth checking
59
69
  if (trimmed.length < effectiveMinLength &&
70
+ !isSingleToken &&
60
71
  !trimmed.includes('?') && !trimmed.includes('?')) {
61
72
  return true;
62
73
  }
package/src/api-server.ts CHANGED
@@ -27,7 +27,9 @@ import { buildManagedCheckpointObservation, buildManagedResumeObservation } from
27
27
  import { runAutoRecall } from "./auto-recall.js";
28
28
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
29
29
  import { runMemoryLint } from "./memory-lint.js";
30
+ import { enforceLocalHttpRequestPolicy, LOCAL_HTTP_HOSTNAME } from "./http-request-policy.js";
30
31
  import * as envConfig from "./env-config.js";
32
+ import { PACKAGE_VERSION } from "./version.js";
31
33
 
32
34
  const config = (loadDotEnv(), loadConfig());
33
35
  const getComponents = createComponentResolver(config);
@@ -625,7 +627,7 @@ async function handleHealth(): Promise<Response> {
625
627
  const stats = await store.stats();
626
628
  return jsonResponse({
627
629
  status: "ok",
628
- version: "1.0.0",
630
+ version: PACKAGE_VERSION,
629
631
  totalMemories: stats.totalCount,
630
632
  timestamp: new Date().toISOString(),
631
633
  });
@@ -649,8 +651,13 @@ const port = clampInt(envConfig.apiPortRaw(), 4318, 1, 65535);
649
651
 
650
652
  const server = Bun.serve({
651
653
  port,
652
- hostname: "127.0.0.1",
654
+ hostname: LOCAL_HTTP_HOSTNAME,
653
655
  async fetch(request) {
656
+ const policy = await enforceLocalHttpRequestPolicy(request);
657
+ if (!policy.allowed) {
658
+ return errorResponse(policy.status, policy.message);
659
+ }
660
+
654
661
  const url = new URL(request.url);
655
662
  const { pathname } = url;
656
663
  const method = request.method;
@@ -1,6 +1,12 @@
1
1
  import { composeResumeContext, type ResumeContextDeps } from "./context-composer.js";
2
2
  import { resolveTier } from "./decay-engine.js";
3
3
  import type { DurableMemoryCategory } from "./memory-schema.js";
4
+ import {
5
+ classifyRecallGate,
6
+ logRecallGateShadow,
7
+ resolveRecallGateMode,
8
+ type RecallGateResult,
9
+ } from "./recall-gate.js";
4
10
  import { governResults, truncateQuery, type GovernorConfig, type GovernorSession } from "./recall-governor.js";
5
11
  import type { RetrievalResult } from "./retriever.js";
6
12
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
@@ -33,6 +39,20 @@ export interface AutoRecallResponse {
33
39
  searchSkippedReason?: string;
34
40
  }
35
41
 
42
+ /** Minimal valid ResumeContextResponse for enforce-mode skip-all short-circuits. */
43
+ function emptyResumeContext(): ResumeContextResponse {
44
+ return {
45
+ summary: "",
46
+ stableContext: [],
47
+ relevantPatterns: [],
48
+ recentCases: [],
49
+ injectionHint: "user_attachment",
50
+ ephemeral: true,
51
+ responseMode: "default",
52
+ generatedAt: new Date().toISOString(),
53
+ };
54
+ }
55
+
36
56
  export async function runAutoRecall(
37
57
  deps: ResumeContextDeps,
38
58
  request: AutoRecallRequest,
@@ -46,6 +66,34 @@ export async function runAutoRecall(
46
66
  throw new Error("message is required");
47
67
  }
48
68
 
69
+ // Recall gate (mlp adaptive-retrieval borrow, three-way per Codex review).
70
+ // Sits BEFORE composeResumeContext — compose itself hits the retriever, so a
71
+ // gate placed after it would save nothing. Default mode is observe: verdicts
72
+ // are computed and shadow-logged but behavior is unchanged until real-sample
73
+ // false-positive rates have been reviewed (shared-behaviors §5).
74
+ const gateMode = resolveRecallGateMode(request.env);
75
+ let gateVerdict: RecallGateResult | undefined;
76
+ if (gateMode !== "off") {
77
+ gateVerdict = classifyRecallGate(message);
78
+ logRecallGateShadow({
79
+ ts: new Date().toISOString(),
80
+ decision: gateVerdict.decision,
81
+ ruleId: gateVerdict.ruleId,
82
+ msgLen: message.length,
83
+ mode: gateMode,
84
+ source: request.operation,
85
+ });
86
+ if (gateMode === "enforce" && gateVerdict.decision === "skip-all") {
87
+ return {
88
+ mode: "resume-only",
89
+ resolvedScope: undefined,
90
+ resume: emptyResumeContext(),
91
+ results: [],
92
+ searchSkippedReason: `recall-gate: skip-all (${gateVerdict.ruleId})`,
93
+ };
94
+ }
95
+ }
96
+
49
97
  const scopeSelection = resolveScopeSelection({
50
98
  scope: request.scope,
51
99
  sessionId: request.sessionId,
@@ -67,6 +115,18 @@ export async function runAutoRecall(
67
115
  profile: request.profile,
68
116
  });
69
117
 
118
+ // Continuity nudges ("继续", "下一步") want the resume context they just got,
119
+ // not an embedding search over their own two characters.
120
+ if (gateMode === "enforce" && gateVerdict?.decision === "resume-only") {
121
+ return {
122
+ mode: "resume-only",
123
+ resolvedScope: resume.resolvedScope || resume.latestCheckpoint?.resolvedScope || scopeSelection.resolvedScope,
124
+ resume,
125
+ results: [],
126
+ searchSkippedReason: `recall-gate: resume-only (${gateVerdict.ruleId})`,
127
+ };
128
+ }
129
+
70
130
  const resolvedScope = request.allScopes
71
131
  ? undefined
72
132
  : (resume.resolvedScope || resume.latestCheckpoint?.resolvedScope || scopeSelection.resolvedScope);
@@ -101,7 +161,7 @@ export async function runAutoRecall(
101
161
  const promoted: RetrievalResult[] = [];
102
162
  const demoted: RetrievalResult[] = [];
103
163
  for (const r of rawResults) {
104
- const tier = resolveTier(r.entry.metadata);
164
+ const tier = resolveTier(r.entry.metadata, r.entry.importance);
105
165
  if (tier === "core" || tier === "working") {
106
166
  promoted.push(r);
107
167
  } else {
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Belief history — copy-on-write archival for belief-changing writes.
3
+ *
4
+ * Problem this solves: the latest-wins and conflict-resolution paths used to call
5
+ * store.update() on the canonical row, replacing text + vector in place. The previous
6
+ * belief was physically gone, so no retrieval strategy could ever answer
7
+ * "what did I believe last month?".
8
+ *
9
+ * Approach: before overwriting canonical row C, copy C's current content into a new
10
+ * `superseded` history row H, then update C in place as before. C keeps its id, so the
11
+ * `deterministicId(scope, canonicalKey)` invariant every canonical lookup depends on is
12
+ * untouched — history lives beside it rather than replacing it.
13
+ *
14
+ * Chain shape (C is the stable canonical id, H1 older than H2):
15
+ * H1 --supersededBy--> C C --supersedes--> H2
16
+ * H2 --supersededBy--> C H2 --supersedes--> H1
17
+ * so traceEvolution() walks backward C → H2 → H1 to rebuild the timeline, and forward
18
+ * from any history row to the belief that is live now.
19
+ */
20
+
21
+ import { deterministicId, type MemoryEntry, type MemoryStore } from "./store.js";
22
+ import { parseEvolution, patchEvolution, type EvolutionMetadata } from "./memory-evolution.js";
23
+ import { detectLang, tokenizeFts } from "./language-hook.js";
24
+ import type { Embedder } from "./embedder.js";
25
+
26
+ /** Namespace for history-row ids — keeps them deterministic (retry-safe) yet distinct from C. */
27
+ const BELIEF_HISTORY_ID_PREFIX = "belief-history:";
28
+
29
+ export interface BeliefHistoryDeps {
30
+ store: Pick<MemoryStore, "store"> & Partial<Pick<MemoryStore, "upsert" | "getById">>;
31
+ embedder: Pick<Embedder, "embedPassage">;
32
+ }
33
+
34
+ export interface BeliefArchiveResult {
35
+ /** Id of the newly written history row — becomes the canonical row's `supersedes`. */
36
+ historyId: string;
37
+ /** When the archived version stopped being the live belief. */
38
+ validUntil: number;
39
+ }
40
+
41
+ /**
42
+ * Resolve a usable vector for the row being archived.
43
+ *
44
+ * store.list() deliberately returns `vector: []` for performance, and that is where the
45
+ * canonical-match candidates come from — so the entry handed to us usually has no vector.
46
+ * Re-read the full row first; only re-embed if the store can't give it back.
47
+ */
48
+ async function resolveVector(deps: BeliefHistoryDeps, current: MemoryEntry): Promise<number[]> {
49
+ if (current.vector && current.vector.length > 0) return current.vector;
50
+
51
+ if (deps.store.getById) {
52
+ const full = await deps.store.getById(current.id);
53
+ if (full?.vector && full.vector.length > 0) return full.vector;
54
+ }
55
+
56
+ return await deps.embedder.embedPassage(current.text);
57
+ }
58
+
59
+ /**
60
+ * Archive the current content of a canonical memory as a `superseded` history row.
61
+ *
62
+ * Throws rather than degrading to a silent overwrite: a belief-changing write that cannot
63
+ * preserve the old belief is exactly the failure this module exists to prevent, so callers
64
+ * must not proceed with the overwrite if this rejects.
65
+ *
66
+ * Known limitation — archive and overwrite are two writes with no transaction around them.
67
+ * If the caller's update() fails after this returns, the history row is already committed
68
+ * while the canonical row still holds that same text, leaving a superseded duplicate of a
69
+ * belief that is in fact still live. Damage is bounded: nothing is destroyed, and the
70
+ * deterministic id means a retry reuses the same row rather than piling up copies.
71
+ */
72
+ export async function archiveBeliefVersion(
73
+ deps: BeliefHistoryDeps,
74
+ current: MemoryEntry,
75
+ options: { now?: number } = {},
76
+ ): Promise<BeliefArchiveResult> {
77
+ const now = options.now ?? Date.now();
78
+ const currentEvo = parseEvolution(current.metadata, current.timestamp);
79
+
80
+ // Text is part of the id, not just (id, timestamp): two belief changes landing in the
81
+ // same millisecond would otherwise collide and the second archive would overwrite the
82
+ // first. With the text folded in, a collision means the content is identical anyway.
83
+ const historyId = deterministicId(
84
+ current.scope,
85
+ `${BELIEF_HISTORY_ID_PREFIX}${current.id}@${current.timestamp}\n${current.text}`,
86
+ );
87
+ if (historyId === current.id) {
88
+ throw new Error(`Belief history id collided with canonical id ${current.id}`);
89
+ }
90
+
91
+ const vector = await resolveVector(deps, current);
92
+ if (vector.length === 0) {
93
+ throw new Error(`Cannot archive belief ${current.id}: no vector available`);
94
+ }
95
+
96
+ // The history row inherits the old metadata wholesale (canonicalKey, boundary, emotion…)
97
+ // and only has its evolution block rewritten to close the interval.
98
+ const historyMetadata = patchEvolution(current.metadata, {
99
+ status: "superseded",
100
+ validFrom: currentEvo.validFrom,
101
+ validUntil: now,
102
+ supersededBy: current.id,
103
+ // Point at the version this one replaced, so the backward walk stays a chain.
104
+ supersedes: currentEvo.supersedes,
105
+ });
106
+
107
+ const historyRow: MemoryEntry = {
108
+ id: historyId,
109
+ text: current.text,
110
+ vector,
111
+ category: current.category,
112
+ scope: current.scope,
113
+ importance: current.importance,
114
+ // Keep the original timestamp: canonical matching scans the most recent
115
+ // CANONICAL_SCAN_LIMIT rows, and stamping history rows "now" would let them crowd
116
+ // live entries out of that window.
117
+ timestamp: current.timestamp,
118
+ metadata: historyMetadata,
119
+ // Derive rather than copy: neither list() nor getById() selects these columns, so
120
+ // `current.language` / `current.fts_text` are all but guaranteed to be undefined here.
121
+ // Falling back to the "en" / raw-text defaults would mislabel Chinese memories and
122
+ // store them unsegmented for FTS — so recompute them the same way live entries do.
123
+ language: current.language || detectLang(current.text),
124
+ fts_text: current.fts_text || tokenizeFts(current.text, current.language || detectLang(current.text)),
125
+ };
126
+
127
+ if (deps.store.upsert) {
128
+ await deps.store.upsert(historyRow);
129
+ } else {
130
+ // Fallback for narrower store deps (mocks): store() stamps its own timestamp, which
131
+ // only costs us the scan-window optimisation above — the history row is still written.
132
+ await deps.store.store(historyRow);
133
+ }
134
+
135
+ return { historyId, validUntil: now };
136
+ }
137
+
138
+ /**
139
+ * Build the evolution block for the canonical row that is about to be overwritten.
140
+ *
141
+ * Callers previously passed the incoming metadata straight to store.update(), which wiped
142
+ * the evolution block along with the text. This merges the incoming metadata with a fresh
143
+ * evolution block that links back to the archived version.
144
+ */
145
+ export function buildSupersedingBeliefMetadata(
146
+ incomingMetadata: string | undefined,
147
+ previousEvolution: EvolutionMetadata,
148
+ historyId: string,
149
+ now: number,
150
+ evolutionNote?: string | null,
151
+ ): string {
152
+ return patchEvolution(incomingMetadata, {
153
+ status: "active",
154
+ version: previousEvolution.version + 1,
155
+ supersedes: historyId,
156
+ supersededBy: null,
157
+ validFrom: now,
158
+ validUntil: null,
159
+ // Access stats belong to the canonical key, not to a single version's text.
160
+ accessCount: previousEvolution.accessCount,
161
+ lastAccessedAt: previousEvolution.lastAccessedAt,
162
+ ...(evolutionNote ? { evolutionNote } : {}),
163
+ });
164
+ }