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.
- package/CHANGELOG.md +37 -0
- package/README.md +34 -9
- package/README_CN.md +37 -12
- package/package.json +6 -5
- package/src/__tests__/access-tracker-novelty.test.ts +66 -0
- package/src/__tests__/api-server-security.test.ts +102 -0
- package/src/__tests__/belief-history.test.ts +220 -0
- package/src/__tests__/capture-engine.test.ts +93 -3
- package/src/__tests__/category-min-scores.test.ts +83 -0
- package/src/__tests__/cluster-consolidation.test.ts +167 -4
- package/src/__tests__/conflict-store.test.ts +46 -2
- package/src/__tests__/consolidation-engine.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +46 -0
- package/src/__tests__/decay-exemption.test.ts +45 -0
- package/src/__tests__/dream-pipeline.test.ts +464 -5
- package/src/__tests__/freshness.test.ts +186 -0
- package/src/__tests__/hotness-blend.test.ts +207 -0
- package/src/__tests__/llm-client-pivot.test.ts +628 -0
- package/src/__tests__/maintenance-locks.test.ts +15 -0
- package/src/__tests__/memory-boundaries.test.ts +32 -0
- package/src/__tests__/noise-prototype-bank.test.ts +280 -0
- package/src/__tests__/pii-redact.test.ts +19 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -0
- package/src/__tests__/preference-matcher.test.ts +54 -2
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
- package/src/__tests__/recall-gate.test.ts +266 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.test.ts +64 -0
- package/src/__tests__/retrieval-eval.test.ts +163 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
- package/src/__tests__/skill-promotion.test.ts +53 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-upsert.test.ts +17 -0
- package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
- package/src/__tests__/ui-server-security.test.ts +93 -0
- package/src/access-tracker.ts +53 -3
- package/src/adaptive-retrieval.ts +11 -0
- package/src/api-server.ts +9 -2
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +88 -11
- package/src/cli.ts +109 -14
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +146 -12
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/data-checkup.ts +1 -1
- package/src/decay-engine.ts +59 -7
- package/src/doctor.ts +4 -2
- package/src/dream-pipeline.ts +359 -11
- package/src/env-config.ts +41 -0
- package/src/eval.ts +154 -11
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/ingest.ts +277 -2
- package/src/kg-extractor.ts +7 -1
- package/src/llm-client.ts +106 -11
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +13 -0
- package/src/mcp-tools-core.ts +5 -1
- package/src/memory-boundaries.ts +23 -2
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-output.ts +16 -2
- package/src/memory-store-port.ts +3 -0
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +52 -2
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/recall-gate.ts +305 -0
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +95 -18
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +70 -5
- package/src/skill-promotion.ts +58 -2
- package/src/store.ts +54 -19
- package/src/term-registry.ts +35 -0
- package/src/ui-server.ts +7 -0
- 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
|
+
});
|
package/src/access-tracker.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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;
|
package/src/auto-recall.ts
CHANGED
|
@@ -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
|
+
}
|