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.
- package/CHANGELOG.md +37 -0
- package/README.md +34 -9
- package/README_CN.md +37 -12
- package/package.json +4 -4
- 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,266 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { runAutoRecall } from "../auto-recall.js";
|
|
7
|
+
import {
|
|
8
|
+
RECALL_GATE_SHADOW_FILE,
|
|
9
|
+
classifyRecallGate,
|
|
10
|
+
resolveRecallGateMode,
|
|
11
|
+
type RecallGateDecision,
|
|
12
|
+
} from "../recall-gate.js";
|
|
13
|
+
import type { RetrievalContext, RetrievalResult } from "../retriever.js";
|
|
14
|
+
|
|
15
|
+
const savedGateEnv = process.env.RECALLNEST_RECALL_GATE;
|
|
16
|
+
const savedDataDirEnv = process.env.RECALLNEST_DATA_DIR;
|
|
17
|
+
const cleanupPaths: string[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
if (savedGateEnv === undefined) delete process.env.RECALLNEST_RECALL_GATE;
|
|
21
|
+
else process.env.RECALLNEST_RECALL_GATE = savedGateEnv;
|
|
22
|
+
if (savedDataDirEnv === undefined) delete process.env.RECALLNEST_DATA_DIR;
|
|
23
|
+
else process.env.RECALLNEST_DATA_DIR = savedDataDirEnv;
|
|
24
|
+
while (cleanupPaths.length > 0) {
|
|
25
|
+
const target = cleanupPaths.pop();
|
|
26
|
+
if (target) rmSync(target, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function tempDataDir(): string {
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), "recallnest-gate-"));
|
|
32
|
+
cleanupPaths.push(dir);
|
|
33
|
+
process.env.RECALLNEST_DATA_DIR = dir;
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function expectDecision(message: string, decision: RecallGateDecision, ruleId?: string) {
|
|
38
|
+
const verdict = classifyRecallGate(message);
|
|
39
|
+
expect(`${message} → ${verdict.decision}`).toBe(`${message} → ${decision}`);
|
|
40
|
+
if (ruleId) expect(verdict.ruleId).toBe(ruleId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("classifyRecallGate — verdict matrix", () => {
|
|
44
|
+
it("force cues win over every short-text rule", () => {
|
|
45
|
+
expectDecision("可以按上次方案做", "full-recall", "force:memory-cue-zh");
|
|
46
|
+
expectDecision("记得我说过什么吗", "full-recall");
|
|
47
|
+
expectDecision("我的偏好是什么", "full-recall");
|
|
48
|
+
expectDecision("do you remember my setup?", "full-recall", "force:memory-cue-en");
|
|
49
|
+
expectDecision("what did we discussed last time", "full-recall");
|
|
50
|
+
// Force cue inside an otherwise long message still labels full-recall.
|
|
51
|
+
const long = `${"背景介绍。".repeat(20)}上次的结论是什么?`;
|
|
52
|
+
expectDecision(long, "full-recall");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("whole-message acks skip; acks with real content pass", () => {
|
|
56
|
+
expectDecision("好的", "skip-all", "skip:ack");
|
|
57
|
+
expectDecision("好的吧。", "skip-all");
|
|
58
|
+
expectDecision("嗯嗯", "skip-all");
|
|
59
|
+
expectDecision("OK", "skip-all");
|
|
60
|
+
expectDecision("Thanks!", "skip-all");
|
|
61
|
+
expectDecision("好的 谢谢", "skip-all");
|
|
62
|
+
expectDecision("收到了哈", "skip-all");
|
|
63
|
+
expectDecision("好的,先跑测试再说", "pass");
|
|
64
|
+
expectDecision("可以先做项1", "pass");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("continuity nudges are resume-only; nudge + content passes", () => {
|
|
68
|
+
expectDecision("继续", "resume-only", "resume:continuity");
|
|
69
|
+
expectDecision("继续吧", "resume-only");
|
|
70
|
+
expectDecision("接着来", "resume-only");
|
|
71
|
+
expectDecision("下一步", "resume-only");
|
|
72
|
+
expectDecision("开始", "resume-only");
|
|
73
|
+
expectDecision("开始吧", "resume-only");
|
|
74
|
+
expectDecision("continue", "resume-only");
|
|
75
|
+
expectDecision("接着改 auto-recall 的闸", "pass");
|
|
76
|
+
// "继续讨论上次的方案" carries a force cue → full-recall, not resume-only.
|
|
77
|
+
expectDecision("继续讨论上次的方案", "full-recall");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("bare CLI invocations skip; CLI words inside prose pass", () => {
|
|
81
|
+
expectDecision("git push", "skip-all", "skip:cli");
|
|
82
|
+
expectDecision("npm test", "skip-all");
|
|
83
|
+
expectDecision("bun test src/__tests__/recall-gate.test.ts", "skip-all");
|
|
84
|
+
expectDecision("git", "skip-all");
|
|
85
|
+
expectDecision("git 为什么失败", "pass");
|
|
86
|
+
expectDecision("why does npm test hang?", "pass");
|
|
87
|
+
expectDecision("测试之前讨论的方案", "full-recall"); // force cue, not cli
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("slash commands and heartbeats skip", () => {
|
|
91
|
+
expectDecision("/compact", "skip-all", "skip:slash");
|
|
92
|
+
expectDecision("/goal 写个爬虫", "skip-all");
|
|
93
|
+
expectDecision("HEARTBEAT_OK", "skip-all", "skip:heartbeat");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("pure emoji skips; bare digits/#/* are NOT emoji", () => {
|
|
97
|
+
expectDecision("👍", "skip-all", "skip:emoji");
|
|
98
|
+
expectDecision("👍👍 🎉", "skip-all");
|
|
99
|
+
expectDecision("🇸🇬", "skip-all");
|
|
100
|
+
expectDecision("42", "pass");
|
|
101
|
+
expectDecision("#1", "pass");
|
|
102
|
+
expectDecision("*", "pass");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("greetings skip as whole messages only", () => {
|
|
106
|
+
expectDecision("你好", "skip-all", "skip:greeting");
|
|
107
|
+
expectDecision("早", "skip-all");
|
|
108
|
+
expectDecision("hello", "skip-all");
|
|
109
|
+
expectDecision("在吗", "skip-all");
|
|
110
|
+
expectDecision("你好,帮我查一下部署状态", "pass");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("long messages without force cues always pass", () => {
|
|
114
|
+
const long = "这条消息足够长,描述了一个具体的问题背景和期望的处理方式,值得走完整的检索管线来补上下文。".repeat(2);
|
|
115
|
+
expect(long.length).toBeGreaterThan(80);
|
|
116
|
+
expectDecision(long, "pass", "pass:long");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("empty input degrades to skip:empty", () => {
|
|
120
|
+
expectDecision(" ", "skip-all", "skip:empty");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("resolveRecallGateMode", () => {
|
|
125
|
+
it("defaults to observe; parses enforce/off; junk falls back to observe", () => {
|
|
126
|
+
expect(resolveRecallGateMode({})).toBe("observe");
|
|
127
|
+
expect(resolveRecallGateMode({ RECALLNEST_RECALL_GATE: "enforce" })).toBe("enforce");
|
|
128
|
+
expect(resolveRecallGateMode({ RECALLNEST_RECALL_GATE: "OFF" })).toBe("off");
|
|
129
|
+
expect(resolveRecallGateMode({ RECALLNEST_RECALL_GATE: "banana" })).toBe("observe");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// --- integration through runAutoRecall ---
|
|
134
|
+
|
|
135
|
+
function buildResult(id: string, text: string): RetrievalResult {
|
|
136
|
+
return {
|
|
137
|
+
entry: {
|
|
138
|
+
id,
|
|
139
|
+
text,
|
|
140
|
+
vector: [],
|
|
141
|
+
category: "events",
|
|
142
|
+
scope: "project:gate",
|
|
143
|
+
importance: 0.8,
|
|
144
|
+
timestamp: Date.parse("2026-07-01T00:00:00.000Z"),
|
|
145
|
+
metadata: "{}",
|
|
146
|
+
},
|
|
147
|
+
score: 0.9,
|
|
148
|
+
sources: { fused: { score: 0.9 } },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function makeDeps() {
|
|
153
|
+
const calls: RetrievalContext[] = [];
|
|
154
|
+
return {
|
|
155
|
+
calls,
|
|
156
|
+
deps: {
|
|
157
|
+
retriever: {
|
|
158
|
+
async retrieve(context: RetrievalContext): Promise<RetrievalResult[]> {
|
|
159
|
+
calls.push(context);
|
|
160
|
+
if (!context.category) return [buildResult("hit-1", "focused hit")];
|
|
161
|
+
return [];
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
checkpointStore: { async getLatest() { return null; } },
|
|
165
|
+
listPins: () => [],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describe("runAutoRecall × recall gate", () => {
|
|
171
|
+
it("observe mode leaves behavior unchanged and appends a shadow entry", async () => {
|
|
172
|
+
const dir = tempDataDir();
|
|
173
|
+
const { deps, calls } = makeDeps();
|
|
174
|
+
|
|
175
|
+
const response = await runAutoRecall(deps, {
|
|
176
|
+
message: "好的",
|
|
177
|
+
scope: "project:gate",
|
|
178
|
+
operation: "test:gate-observe",
|
|
179
|
+
env: { RECALLNEST_RECALL_GATE: "observe" } as NodeJS.ProcessEnv,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Unchanged behavior: full resume+search pipeline still ran.
|
|
183
|
+
expect(response.mode).toBe("resume+search");
|
|
184
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
185
|
+
expect(response.results.length).toBe(1);
|
|
186
|
+
|
|
187
|
+
const shadowPath = join(dir, RECALL_GATE_SHADOW_FILE);
|
|
188
|
+
expect(existsSync(shadowPath)).toBe(true);
|
|
189
|
+
const entry = JSON.parse(readFileSync(shadowPath, "utf8").trim().split("\n")[0] ?? "{}");
|
|
190
|
+
expect(entry.decision).toBe("skip-all");
|
|
191
|
+
expect(entry.ruleId).toBe("skip:ack");
|
|
192
|
+
expect(entry.mode).toBe("observe");
|
|
193
|
+
expect(entry.msgLen).toBe(2);
|
|
194
|
+
expect(entry.source).toBe("test:gate-observe");
|
|
195
|
+
expect(JSON.stringify(entry)).not.toContain("好的"); // never log message text
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("enforce + skip-all short-circuits before any retriever call", async () => {
|
|
199
|
+
tempDataDir();
|
|
200
|
+
const { deps, calls } = makeDeps();
|
|
201
|
+
|
|
202
|
+
const response = await runAutoRecall(deps, {
|
|
203
|
+
message: "👍",
|
|
204
|
+
scope: "project:gate",
|
|
205
|
+
operation: "test:gate-enforce-skip",
|
|
206
|
+
env: { RECALLNEST_RECALL_GATE: "enforce" } as NodeJS.ProcessEnv,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(response.mode).toBe("resume-only");
|
|
210
|
+
expect(response.results.length).toBe(0);
|
|
211
|
+
expect(response.searchSkippedReason).toContain("skip-all");
|
|
212
|
+
expect(response.searchSkippedReason).toContain("skip:emoji");
|
|
213
|
+
expect(calls.length).toBe(0); // neither compose nor focused search hit the retriever
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("enforce + resume-only keeps resume context but skips the focused search", async () => {
|
|
217
|
+
tempDataDir();
|
|
218
|
+
const { deps, calls } = makeDeps();
|
|
219
|
+
|
|
220
|
+
const response = await runAutoRecall(deps, {
|
|
221
|
+
message: "继续",
|
|
222
|
+
scope: "project:gate",
|
|
223
|
+
operation: "test:gate-enforce-resume",
|
|
224
|
+
env: { RECALLNEST_RECALL_GATE: "enforce" } as NodeJS.ProcessEnv,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(response.mode).toBe("resume-only");
|
|
228
|
+
expect(response.results.length).toBe(0);
|
|
229
|
+
expect(response.searchSkippedReason).toContain("resume-only");
|
|
230
|
+
// Resume composition ran (retriever called with categories), but no
|
|
231
|
+
// focused search (which would be the category-less call).
|
|
232
|
+
const focusedCalls = calls.filter((c) => !c.category);
|
|
233
|
+
expect(focusedCalls.length).toBe(0);
|
|
234
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("enforce mode leaves normal content untouched", async () => {
|
|
238
|
+
tempDataDir();
|
|
239
|
+
const { deps } = makeDeps();
|
|
240
|
+
|
|
241
|
+
const response = await runAutoRecall(deps, {
|
|
242
|
+
message: "帮我看看 retriever 的打分逻辑有没有问题",
|
|
243
|
+
scope: "project:gate",
|
|
244
|
+
operation: "test:gate-enforce-pass",
|
|
245
|
+
env: { RECALLNEST_RECALL_GATE: "enforce" } as NodeJS.ProcessEnv,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(response.mode).toBe("resume+search");
|
|
249
|
+
expect(response.results.length).toBe(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("off mode computes nothing and writes no shadow log", async () => {
|
|
253
|
+
const dir = tempDataDir();
|
|
254
|
+
const { deps } = makeDeps();
|
|
255
|
+
|
|
256
|
+
const response = await runAutoRecall(deps, {
|
|
257
|
+
message: "好的",
|
|
258
|
+
scope: "project:gate",
|
|
259
|
+
operation: "test:gate-off",
|
|
260
|
+
env: { RECALLNEST_RECALL_GATE: "off" } as NodeJS.ProcessEnv,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(response.mode).toBe("resume+search");
|
|
264
|
+
expect(existsSync(join(dir, RECALL_GATE_SHADOW_FILE))).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
import { resolveDbPath, resolveDataDir, type LocalMemoryConfig } from "../runtime-config.js";
|
|
7
|
+
|
|
8
|
+
const REPO_ROOT = resolve(import.meta.dir, "../..");
|
|
9
|
+
|
|
10
|
+
function cfg(dbPath: string): LocalMemoryConfig {
|
|
11
|
+
return {
|
|
12
|
+
dbPath,
|
|
13
|
+
embedding: { provider: "jina", apiKey: "x", model: "m" },
|
|
14
|
+
sources: {},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Regression guard for the 2026-07-19 audit finding: maintenance scripts each
|
|
20
|
+
* resolved the database on their own — some hardcoded "./data/lancedb", others
|
|
21
|
+
* read `config.database?.path`, a key that does not exist in LocalMemoryConfig
|
|
22
|
+
* and therefore always fell through to the hardcoded default. A health check or
|
|
23
|
+
* cleanup could then report on an empty directory while the server used a
|
|
24
|
+
* different database entirely.
|
|
25
|
+
*/
|
|
26
|
+
describe("resolveDbPath", () => {
|
|
27
|
+
it("resolves a relative dbPath against the repo root, not process.cwd()", () => {
|
|
28
|
+
expect(resolveDbPath(cfg("./data/lancedb"))).toBe(resolve(REPO_ROOT, "data/lancedb"));
|
|
29
|
+
expect(resolveDbPath(cfg("data/lancedb"))).toBe(resolve(REPO_ROOT, "data/lancedb"));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("expands a leading ~/ to the home directory", () => {
|
|
33
|
+
expect(resolveDbPath(cfg("~/.recallnest/data/lancedb"))).toBe(
|
|
34
|
+
resolve(homedir(), ".recallnest/data/lancedb")
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps an absolute dbPath unchanged", () => {
|
|
39
|
+
expect(resolveDbPath(cfg("/tmp/rn-abs/lancedb"))).toBe("/tmp/rn-abs/lancedb");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("always returns an absolute path", () => {
|
|
43
|
+
for (const p of ["./data/lancedb", "data/lancedb", "~/x/lancedb", "/tmp/y"]) {
|
|
44
|
+
expect(resolveDbPath(cfg(p)).startsWith("/")).toBe(true);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("puts the sidecar data dir directly beside the database", () => {
|
|
49
|
+
expect(resolveDataDir(cfg("~/.recallnest/data/lancedb"))).toBe(
|
|
50
|
+
resolve(homedir(), ".recallnest/data")
|
|
51
|
+
);
|
|
52
|
+
expect(resolveDataDir(cfg("./data/lancedb"))).toBe(resolve(REPO_ROOT, "data"));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("scripts resolve the database through runtime-config", () => {
|
|
57
|
+
it("no script hardcodes a lancedb path", () => {
|
|
58
|
+
const dir = resolve(REPO_ROOT, "scripts");
|
|
59
|
+
const offenders: string[] = [];
|
|
60
|
+
|
|
61
|
+
// Two shapes of the same mistake. The second was missed by the first
|
|
62
|
+
// revision of this guard: health-check.ts built its path with
|
|
63
|
+
// path.join(dir, "..", "data", "lancedb"), so no single string literal
|
|
64
|
+
// ever contained "data/lancedb".
|
|
65
|
+
const LITERAL_PATH = /["'`][^"'`]*data\/lancedb[^"'`]*["'`]/;
|
|
66
|
+
const JOINED_PATH = /\b(join|resolve)\s*\([^)]*["'`][^"'`]*lancedb[^"'`]*["'`]/i;
|
|
67
|
+
|
|
68
|
+
for (const name of readdirSync(dir)) {
|
|
69
|
+
if (!name.endsWith(".ts")) continue;
|
|
70
|
+
const src = readFileSync(resolve(dir, name), "utf-8");
|
|
71
|
+
for (const [i, line] of src.split("\n").entries()) {
|
|
72
|
+
const code = line.split("//")[0];
|
|
73
|
+
if (/^\s*\*/.test(line)) continue; // block-comment body
|
|
74
|
+
if (/@lancedb\/lancedb/.test(code)) continue; // the package import itself
|
|
75
|
+
if (LITERAL_PATH.test(code) || JOINED_PATH.test(code)) {
|
|
76
|
+
offenders.push(`${name}:${i + 1}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(offenders).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("no src file re-implements dbPath resolution instead of calling resolveDbPath", () => {
|
|
85
|
+
// cli.ts once duplicated the resolve() expression for its destructive
|
|
86
|
+
// "clear index" path, and doctor.ts resolved against process.cwd() — so the
|
|
87
|
+
// health check could report on a different directory than the server used.
|
|
88
|
+
const dir = resolve(REPO_ROOT, "src");
|
|
89
|
+
const offenders: string[] = [];
|
|
90
|
+
|
|
91
|
+
const walk = (d: string): void => {
|
|
92
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
93
|
+
if (entry.name === "__tests__" || entry.name === "node_modules") continue;
|
|
94
|
+
const full = resolve(d, entry.name);
|
|
95
|
+
if (entry.isDirectory()) {
|
|
96
|
+
walk(full);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!entry.name.endsWith(".ts") || entry.name === "runtime-config.ts") continue;
|
|
100
|
+
const src = readFileSync(full, "utf-8");
|
|
101
|
+
for (const [i, line] of src.split("\n").entries()) {
|
|
102
|
+
if (/expandHome\(\s*\w+\.dbPath\s*\)/.test(line)) {
|
|
103
|
+
offenders.push(`${entry.name}:${i + 1}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
walk(dir);
|
|
109
|
+
|
|
110
|
+
expect(offenders).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("no script reads the non-existent config.database key", () => {
|
|
114
|
+
const dir = resolve(REPO_ROOT, "scripts");
|
|
115
|
+
const offenders: string[] = [];
|
|
116
|
+
|
|
117
|
+
for (const name of readdirSync(dir)) {
|
|
118
|
+
if (!name.endsWith(".ts")) continue;
|
|
119
|
+
const src = readFileSync(resolve(dir, name), "utf-8");
|
|
120
|
+
for (const [i, line] of src.split("\n").entries()) {
|
|
121
|
+
// Skip comments — documenting the retired key must not fail the guard.
|
|
122
|
+
if (/^\s*(\/\/|\*|\/\*)/.test(line)) continue;
|
|
123
|
+
const code = line.split("//")[0];
|
|
124
|
+
if (/config\.database\b/.test(code)) offenders.push(`${name}:${i + 1}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
expect(offenders).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { isDecayExempt, resolveTier } from "../decay-engine.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* importance 是 MemoryEntry 的列字段,metadata 里没有它——顶层住着的是
|
|
7
|
+
* accessCount / lastAccessedAt / tier(access-tracker 写的)。所以 heuristic
|
|
8
|
+
* 分支必须由调用方把 entry.importance 传进来。
|
|
9
|
+
* 2026-07 实测:全库 115688 行里 3214 行走这个分支,其中 metadata 顶层有
|
|
10
|
+
* importance 的是 0 行——也就是这些条目当时全部被压成了 peripheral。
|
|
11
|
+
*/
|
|
12
|
+
function meta(extra: Record<string, unknown> = {}): string {
|
|
13
|
+
return JSON.stringify({ source: "cc", evolution: { status: "active" }, ...extra });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("resolveTier", () => {
|
|
17
|
+
it("拿到 entry.importance 时,高 importance 条目升到对应档位", () => {
|
|
18
|
+
// 真实样本形状:importance 0.9 的 patterns,此前一直被判 peripheral
|
|
19
|
+
expect(resolveTier(meta(), 0.9)).toBe("working");
|
|
20
|
+
expect(resolveTier(meta(), 0.96)).toBe("core");
|
|
21
|
+
expect(resolveTier(meta(), 0.5)).toBe("peripheral");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("不传 entry.importance 时退回 0——这正是修复前的行为", () => {
|
|
25
|
+
// 保持向后兼容:老调用方不传就还是老结果,不会炸
|
|
26
|
+
expect(resolveTier(meta(), undefined)).toBe("peripheral");
|
|
27
|
+
expect(resolveTier(meta())).toBe("peripheral");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("显式 tier 优先,entry.importance 不能覆盖它", () => {
|
|
31
|
+
// access-tracker 算过一次就会把 tier 写进顶层,那是权威值
|
|
32
|
+
expect(resolveTier(meta({ tier: "peripheral" }), 0.99)).toBe("peripheral");
|
|
33
|
+
expect(resolveTier(meta({ tier: "core" }), 0.1)).toBe("core");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("metadata 顶层真有 importance 时以它为准", () => {
|
|
37
|
+
// 顺序:meta.importance > entryImportance > 0
|
|
38
|
+
expect(resolveTier(meta({ importance: 0.85 }), 0.1)).toBe("working");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("accessCount 仍走 metadata 顶层——那是 access-tracker 实际写入的位置", () => {
|
|
42
|
+
expect(resolveTier(meta({ accessCount: 10 }), 0.1)).toBe("core");
|
|
43
|
+
expect(resolveTier(meta({ accessCount: 3 }), 0.1)).toBe("working");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("没有 metadata 或解析失败时给 peripheral", () => {
|
|
47
|
+
expect(resolveTier(undefined, 0.99)).toBe("peripheral");
|
|
48
|
+
expect(resolveTier("{ 坏掉的 json", 0.99)).toBe("peripheral");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("isDecayExempt", () => {
|
|
53
|
+
it("core + 高 importance 免衰减——importance 来自入参而非 metadata", () => {
|
|
54
|
+
// 修复前:resolveTierFromMeta 读不到 importance,tier 恒为 peripheral,
|
|
55
|
+
// 于是这条规则对没有显式 tier 的条目从来没生效过。
|
|
56
|
+
const noExplicitTier = JSON.stringify({ source: "cc", lastAccessedAt: 0 });
|
|
57
|
+
expect(isDecayExempt(noExplicitTier, 0.96)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("importance 不够高时不豁免", () => {
|
|
61
|
+
const noExplicitTier = JSON.stringify({ source: "cc", lastAccessedAt: 0 });
|
|
62
|
+
expect(isDecayExempt(noExplicitTier, 0.9)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { scoreRetrievalCase, type RetrievalEvalCase } from "../eval.js";
|
|
5
|
+
|
|
6
|
+
// 对齐 eval/cases.json 的真实形状:20 个 case 全部只声明 expectAny +
|
|
7
|
+
// expectScopePrefixes,没有一个声明 expectAll 或 forbid。
|
|
8
|
+
function makeCase(overrides: Partial<RetrievalEvalCase> = {}): RetrievalEvalCase {
|
|
9
|
+
return {
|
|
10
|
+
name: "case",
|
|
11
|
+
query: "q",
|
|
12
|
+
expectAny: ["alpha", "beta", "gamma", "delta"],
|
|
13
|
+
expectScopePrefixes: ["project:"],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hits(texts: string[], scope = "project:test") {
|
|
19
|
+
return texts.map((text) => ({ entry: { text, scope } }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("scoreRetrievalCase", () => {
|
|
23
|
+
it("case 没声明的期望不再白送权重", () => {
|
|
24
|
+
// 这条守的就是那个漏洞:expectAll 在所有真实 case 里都没声明,
|
|
25
|
+
// 白送它的 0.3 意味着 expectAny 命中 1 个再加 scope 前缀命中,
|
|
26
|
+
// 就正好压在 0.7 及格线上——检索质量其实很差。
|
|
27
|
+
const weak = scoreRetrievalCase(makeCase(), hits(["alpha only"]));
|
|
28
|
+
|
|
29
|
+
expect(weak.matchedAny).toEqual(["alpha"]);
|
|
30
|
+
expect(weak.matchedScopes).toEqual(["project:"]);
|
|
31
|
+
// (0.4×1/4 + 0.2×1/1) / 0.6 × 0.9 + 0.1 = 0.55
|
|
32
|
+
expect(weak.score).toBeCloseTo(0.55, 5);
|
|
33
|
+
expect(weak.passed).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("同样形状下 expectAny 要命中一半才刚好摸到及格线", () => {
|
|
37
|
+
const report = scoreRetrievalCase(makeCase(), hits(["alpha beta"]));
|
|
38
|
+
|
|
39
|
+
expect(report.matchedAny).toEqual(["alpha", "beta"]);
|
|
40
|
+
// (0.4×2/4 + 0.2) / 0.6 × 0.9 + 0.1 = 0.7
|
|
41
|
+
expect(report.score).toBeCloseTo(0.7, 5);
|
|
42
|
+
expect(report.passed).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("声明的期望全部命中给满分", () => {
|
|
46
|
+
const report = scoreRetrievalCase(makeCase(), hits(["alpha beta gamma delta"]));
|
|
47
|
+
|
|
48
|
+
expect(report.score).toBeCloseTo(1.0, 5);
|
|
49
|
+
expect(report.passed).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("多声明一个维度不会稀释满分——归一化是重分配不是膨胀", () => {
|
|
53
|
+
const withAll = scoreRetrievalCase(
|
|
54
|
+
makeCase({ expectAll: ["alpha", "beta"] }),
|
|
55
|
+
hits(["alpha beta gamma delta"]),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(withAll.score).toBeCloseTo(1.0, 5);
|
|
59
|
+
expect(withAll.passed).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("声明了 expectAll 却全 miss 会把分数拉到及格线上", () => {
|
|
63
|
+
const report = scoreRetrievalCase(
|
|
64
|
+
makeCase({ expectAll: ["epsilon", "zeta"] }),
|
|
65
|
+
hits(["alpha beta gamma delta"]),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(report.matchedAll).toEqual([]);
|
|
69
|
+
// 声明总权重 0.9 里挣到 0.4 + 0 + 0.2,归一化后加非空奖励正好 0.7——
|
|
70
|
+
// 这是"仍能通过"的最弱一档:其余全中、只丢掉整个 expectAll。
|
|
71
|
+
expect(report.score).toBeCloseTo((0.6 / 0.9) * 0.9 + 0.1, 5);
|
|
72
|
+
expect(report.passed).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("再滑掉一个维度就掉到线下", () => {
|
|
76
|
+
const report = scoreRetrievalCase(
|
|
77
|
+
makeCase({ expectAll: ["epsilon", "zeta"] }),
|
|
78
|
+
hits(["alpha beta gamma"]), // expectAny 4 中 3,expectAll 全 miss
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(report.score).toBeLessThan(0.7);
|
|
82
|
+
expect(report.passed).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("只声明一个维度时该维度独占全部权重", () => {
|
|
86
|
+
const single = makeCase({ expectScopePrefixes: undefined });
|
|
87
|
+
|
|
88
|
+
const full = scoreRetrievalCase(single, hits(["alpha beta gamma delta"]));
|
|
89
|
+
expect(full.score).toBeCloseTo(1.0, 5);
|
|
90
|
+
|
|
91
|
+
const half = scoreRetrievalCase(single, hits(["alpha beta"]));
|
|
92
|
+
// 4 个词命中 2 个 → 拿走整个信封的一半,再加非空奖励
|
|
93
|
+
expect(half.score).toBeCloseTo(0.5 * 0.9 + 0.1, 5);
|
|
94
|
+
expect(half.passed).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("expectAll 按命中比例计分,不是全有或全无", () => {
|
|
98
|
+
const report = scoreRetrievalCase(
|
|
99
|
+
makeCase({ expectAny: ["alpha"], expectScopePrefixes: undefined, expectAll: ["beta", "epsilon"] }),
|
|
100
|
+
hits(["alpha beta"]),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(report.matchedAll).toEqual(["beta"]);
|
|
104
|
+
// (0.4×1 + 0.3×1/2) / 0.7 × 0.9 + 0.1
|
|
105
|
+
expect(report.score).toBeCloseTo((0.55 / 0.7) * 0.9 + 0.1, 5);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("scope 前缀也按命中比例计分", () => {
|
|
109
|
+
const report = scoreRetrievalCase(
|
|
110
|
+
makeCase({ expectAny: undefined, expectScopePrefixes: ["project:", "hippo:"] }),
|
|
111
|
+
hits(["随便什么内容"]),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(report.matchedScopes).toEqual(["project:"]);
|
|
115
|
+
// 2 个前缀命中 1 个 → 0.5 × 0.9 + 0.1
|
|
116
|
+
expect(report.score).toBeCloseTo(0.55, 5);
|
|
117
|
+
expect(report.passed).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("命中 forbid 既扣分又直接判 fail", () => {
|
|
121
|
+
const report = scoreRetrievalCase(
|
|
122
|
+
makeCase({ forbid: ["secret"] }),
|
|
123
|
+
hits(["alpha beta gamma delta secret"]),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(report.forbiddenMatches).toEqual(["secret"]);
|
|
127
|
+
expect(report.score).toBeCloseTo(0.7, 5);
|
|
128
|
+
// 分数够线,但命中禁词一律判不通过
|
|
129
|
+
expect(report.passed).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("空召回拿不到非空奖励,得 0 分", () => {
|
|
133
|
+
const report = scoreRetrievalCase(makeCase(), []);
|
|
134
|
+
|
|
135
|
+
expect(report.hitCount).toBe(0);
|
|
136
|
+
expect(report.score).toBe(0);
|
|
137
|
+
expect(report.passed).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("一个期望都没声明的畸形 case 拿不到及格分", () => {
|
|
141
|
+
const report = scoreRetrievalCase({ name: "empty", query: "q" }, hits(["anything at all"]));
|
|
142
|
+
|
|
143
|
+
// 只剩非空奖励 0.1;改之前这里是三个维度全额白送 = 满分 1.0
|
|
144
|
+
expect(report.score).toBeCloseTo(0.1, 5);
|
|
145
|
+
expect(report.passed).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("生产 case 集:完全不相关的召回不得有任何一个 case 过线", () => {
|
|
149
|
+
// 守住评分器不再退回白送状态——不依赖 case 集当前的写法,
|
|
150
|
+
// 以后无论 eval/cases.json 怎么增删,零匹配都必须是零通过。
|
|
151
|
+
const cases = JSON.parse(
|
|
152
|
+
readFileSync(new URL("../../eval/cases.json", import.meta.url), "utf-8"),
|
|
153
|
+
) as RetrievalEvalCase[];
|
|
154
|
+
expect(cases.length).toBeGreaterThan(0);
|
|
155
|
+
|
|
156
|
+
const irrelevant = [{ entry: { text: "完全无关的内容", scope: "unrelated/scope" } }];
|
|
157
|
+
const leaked = cases
|
|
158
|
+
.map((c) => scoreRetrievalCase(c, irrelevant))
|
|
159
|
+
.filter((r) => r.passed)
|
|
160
|
+
.map((r) => r.name);
|
|
161
|
+
expect(leaked).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
});
|