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,123 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createRetriever } from "../retriever.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 回归:retrieve() 在分派前必须等 store 完成惰性初始化。
|
|
7
|
+
*
|
|
8
|
+
* 真实 MemoryStore 的 hasFtsSupport 在 ensureInitialized() 跑完前恒为 false
|
|
9
|
+
* (ftsIndexCreated 初值 false,建索引发生在 doInitialize 里)。retriever.ts 的
|
|
10
|
+
* 分派条件是 `mode === "vector" || !this.store.hasFtsSupport`,若不先 await
|
|
11
|
+
* store.ready(),hybrid 模式的**首次**检索会读到初始化前的 false 并静默落回
|
|
12
|
+
* vector 路径——同一实例第二次才正常。
|
|
13
|
+
*
|
|
14
|
+
* 该缺陷自 c10607a(2026-03-03 初始版本)就在,长期未暴露:生产 mode 一直是
|
|
15
|
+
* "vector",第一个条件短路;而 eval 每 case 新建组件,每个 case 都停在"首次",
|
|
16
|
+
* 于是 eval 从未测到过 hybrid 路径。
|
|
17
|
+
*
|
|
18
|
+
* 下面的 fake store 用 getter + ready() 精确复现这个时序:不修复则 bm25Search
|
|
19
|
+
* 不会被调用。
|
|
20
|
+
*/
|
|
21
|
+
function buildResult(id: string, text: string) {
|
|
22
|
+
return {
|
|
23
|
+
entry: {
|
|
24
|
+
id,
|
|
25
|
+
text,
|
|
26
|
+
vector: [1, 0, 0],
|
|
27
|
+
category: "preferences",
|
|
28
|
+
scope: "memory:test",
|
|
29
|
+
importance: 0.8,
|
|
30
|
+
timestamp: Date.parse("2026-08-07T00:00:00.000Z"),
|
|
31
|
+
metadata: JSON.stringify({}),
|
|
32
|
+
},
|
|
33
|
+
score: 0.8,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fakeEmbedder = {
|
|
38
|
+
async embedQuery() {
|
|
39
|
+
return [1, 0, 0];
|
|
40
|
+
},
|
|
41
|
+
async embedPassage() {
|
|
42
|
+
return [1, 0, 0];
|
|
43
|
+
},
|
|
44
|
+
} as any;
|
|
45
|
+
|
|
46
|
+
function buildLazyStore() {
|
|
47
|
+
const calls = { bm25: 0, vector: 0, ready: 0 };
|
|
48
|
+
let initialized = false;
|
|
49
|
+
|
|
50
|
+
const store = {
|
|
51
|
+
// 复现真实时序:初始化完成前恒 false
|
|
52
|
+
get hasFtsSupport() {
|
|
53
|
+
return initialized;
|
|
54
|
+
},
|
|
55
|
+
async ready() {
|
|
56
|
+
calls.ready += 1;
|
|
57
|
+
initialized = true;
|
|
58
|
+
},
|
|
59
|
+
async vectorSearch() {
|
|
60
|
+
calls.vector += 1;
|
|
61
|
+
return [buildResult("vec-1", "vector hit")];
|
|
62
|
+
},
|
|
63
|
+
async bm25Search() {
|
|
64
|
+
calls.bm25 += 1;
|
|
65
|
+
return [buildResult("bm25-1", "bm25 hit")];
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return { store, calls };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const LOOSE_CONFIG = {
|
|
73
|
+
rerank: "none" as const,
|
|
74
|
+
filterNoise: false,
|
|
75
|
+
hardMinScore: 0,
|
|
76
|
+
minScore: 0,
|
|
77
|
+
recencyWeight: 0,
|
|
78
|
+
timeDecayHalfLifeDays: 0,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
describe("retrieve() 分派与 store 惰性初始化的时序", () => {
|
|
82
|
+
it("hybrid 模式的首次检索不因惰性初始化落回 vector 路径", async () => {
|
|
83
|
+
const { store, calls } = buildLazyStore();
|
|
84
|
+
const retriever = createRetriever(store as any, fakeEmbedder, {
|
|
85
|
+
...LOOSE_CONFIG,
|
|
86
|
+
mode: "hybrid",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await retriever.retrieve({ query: "双机契约 同步", limit: 5 });
|
|
90
|
+
|
|
91
|
+
// 修复前:hasFtsSupport 读到初始化前的 false → 落回 vectorOnly → bm25 为 0。
|
|
92
|
+
expect(calls.ready).toBeGreaterThan(0);
|
|
93
|
+
expect(calls.bm25).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("vector 模式不受影响——短路在第一个条件,不走 BM25", async () => {
|
|
97
|
+
const { store, calls } = buildLazyStore();
|
|
98
|
+
const retriever = createRetriever(store as any, fakeEmbedder, {
|
|
99
|
+
...LOOSE_CONFIG,
|
|
100
|
+
mode: "vector",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await retriever.retrieve({ query: "双机契约 同步", limit: 5 });
|
|
104
|
+
|
|
105
|
+
// 这条守的是"修复不得改变生产行为":生产 mode 就是 vector。
|
|
106
|
+
expect(calls.bm25).toBe(0);
|
|
107
|
+
expect(calls.vector).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("琐碎 query 走 skip 分支时不触发初始化", async () => {
|
|
111
|
+
const { store, calls } = buildLazyStore();
|
|
112
|
+
const retriever = createRetriever(store as any, fakeEmbedder, {
|
|
113
|
+
...LOOSE_CONFIG,
|
|
114
|
+
mode: "hybrid",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// shouldSkipRetrieval 在分派之前提前 return,await ready() 不该被执行到。
|
|
118
|
+
await retriever.retrieve({ query: "ok", limit: 5 });
|
|
119
|
+
|
|
120
|
+
expect(calls.ready).toBe(0);
|
|
121
|
+
expect(calls.vector).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -599,3 +599,56 @@ describe("A2 verifier integration", () => {
|
|
|
599
599
|
expect(text).toContain("fake_cmd");
|
|
600
600
|
});
|
|
601
601
|
});
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Read evidence (Artel read-driven promotion) — aggregateReadEvidence / applyReadBoost
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
import { aggregateReadEvidence, applyReadBoost } from "../skill-promotion.js";
|
|
608
|
+
|
|
609
|
+
describe("aggregateReadEvidence", () => {
|
|
610
|
+
it("sums accessCount and unions readerIds across entries", () => {
|
|
611
|
+
const entries = [
|
|
612
|
+
{ metadata: JSON.stringify({ accessCount: 3, readerIds: ["r-a", "r-b"], distinctReaderCount: 2 }) },
|
|
613
|
+
{ metadata: JSON.stringify({ accessCount: 2, readerIds: ["r-b", "r-c"], distinctReaderCount: 2 }) },
|
|
614
|
+
];
|
|
615
|
+
const ev = aggregateReadEvidence(entries);
|
|
616
|
+
expect(ev.totalAccess).toBe(5);
|
|
617
|
+
expect(ev.distinctReaders).toBe(3); // union {r-a, r-b, r-c}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("falls back to saturated per-entry count when readerIds capped", () => {
|
|
621
|
+
// READER_ID_CAP 饱和后 readerIds 停增但 distinctReaderCount 保留饱和值
|
|
622
|
+
const entries = [
|
|
623
|
+
{ metadata: JSON.stringify({ accessCount: 10, readerIds: ["r-a"], distinctReaderCount: 8 }) },
|
|
624
|
+
];
|
|
625
|
+
const ev = aggregateReadEvidence(entries);
|
|
626
|
+
expect(ev.distinctReaders).toBe(8); // max(union=1, saturated=8)
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("tolerates missing and broken metadata", () => {
|
|
630
|
+
const ev = aggregateReadEvidence([
|
|
631
|
+
{ metadata: undefined },
|
|
632
|
+
{ metadata: "not-json{" },
|
|
633
|
+
{ metadata: JSON.stringify({ accessCount: 1 }) },
|
|
634
|
+
]);
|
|
635
|
+
expect(ev.totalAccess).toBe(1);
|
|
636
|
+
expect(ev.distinctReaders).toBe(0);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
describe("applyReadBoost", () => {
|
|
641
|
+
it("adds no boost for zero readers", () => {
|
|
642
|
+
expect(applyReadBoost(0.5, 0)).toBe(0.5);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("scales boost linearly and saturates at 4 readers", () => {
|
|
646
|
+
expect(applyReadBoost(0.5, 2)).toBeCloseTo(0.5 + 0.075, 5); // half of max 0.15
|
|
647
|
+
expect(applyReadBoost(0.5, 4)).toBeCloseTo(0.65, 5); // full boost
|
|
648
|
+
expect(applyReadBoost(0.5, 12)).toBeCloseTo(0.65, 5); // saturated
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("caps combined confidence at 0.99", () => {
|
|
652
|
+
expect(applyReadBoost(0.95, 4)).toBe(0.99);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { selectStableResults } from "../context-composer-stable-selection.js";
|
|
4
|
+
import { ResumeContextResponseSchema } from "../session-schema.js";
|
|
5
|
+
import type { RetrievalResult } from "../retriever.js";
|
|
6
|
+
|
|
7
|
+
// selectStableResults 产出的行必须过得了 stableContext 的 schema 上限。
|
|
8
|
+
// 这条曾经断过:formatStableResult 截到 230、schema 上限 220,中间的
|
|
9
|
+
// interleaveUnique(context-composer-stable.ts:199-206)不再截一次,
|
|
10
|
+
// 于是 221~230 字符的行会让 composeResumeContext 末尾的
|
|
11
|
+
// ResumeContextResponseSchema.parse 抛错,**整个 resume_context 调用失败**
|
|
12
|
+
// —— 不是降级,是抛异常。实测 14 条真实问法里 1 条命中。
|
|
13
|
+
//
|
|
14
|
+
// 用长文本喂进去,逼出最长可能的输出,再拿真 schema 验,
|
|
15
|
+
// 这样将来任一侧的数字被改动都会在这里红,而不是等生产上抛。
|
|
16
|
+
|
|
17
|
+
const SCHEMA_MAX = 220;
|
|
18
|
+
|
|
19
|
+
function longPreference(): RetrievalResult {
|
|
20
|
+
return {
|
|
21
|
+
entry: {
|
|
22
|
+
id: "test-long-pref",
|
|
23
|
+
// 远超上限的正文,确保格式化后会吃满截断长度
|
|
24
|
+
text: `用户确立的一条长期偏好:${"跨端协作时先确认目标仓库与路径再动手,".repeat(30)}`,
|
|
25
|
+
vector: [],
|
|
26
|
+
category: "preferences",
|
|
27
|
+
scope: "memory:pivot",
|
|
28
|
+
importance: 0.9,
|
|
29
|
+
timestamp: Date.now(),
|
|
30
|
+
},
|
|
31
|
+
score: 1,
|
|
32
|
+
sources: {},
|
|
33
|
+
} as RetrievalResult;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("stableContext 长度契约", () => {
|
|
37
|
+
it("超长偏好格式化后不超过 schema 上限", () => {
|
|
38
|
+
const lines = selectStableResults("preferences", [longPreference()], 3, {
|
|
39
|
+
taskSeed: "跨端协作要注意什么",
|
|
40
|
+
});
|
|
41
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
expect(line.length).toBeLessThanOrEqual(SCHEMA_MAX);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("这些行能通过真实的 ResumeContextResponseSchema", () => {
|
|
48
|
+
const stableContext = selectStableResults("preferences", [longPreference()], 3, {
|
|
49
|
+
taskSeed: "跨端协作要注意什么",
|
|
50
|
+
});
|
|
51
|
+
// 只验长度这一维:其余必填字段给最小合法值,schema 变动时这里也会跟着红
|
|
52
|
+
expect(() =>
|
|
53
|
+
ResumeContextResponseSchema.parse({
|
|
54
|
+
summary: "test",
|
|
55
|
+
generatedAt: new Date().toISOString(),
|
|
56
|
+
stableContext,
|
|
57
|
+
relevantPatterns: [],
|
|
58
|
+
recentCases: [],
|
|
59
|
+
}),
|
|
60
|
+
).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { readConsistencyInterval as envReadConsistencyInterval } from "../env-config.js";
|
|
7
|
+
import { MemoryStore } from "../store.js";
|
|
8
|
+
|
|
9
|
+
const cleanupPaths: string[] = [];
|
|
10
|
+
const savedEnv = process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
while (cleanupPaths.length > 0) {
|
|
14
|
+
const target = cleanupPaths.pop();
|
|
15
|
+
if (target) {
|
|
16
|
+
rmSync(target, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (savedEnv === undefined) {
|
|
20
|
+
delete process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL;
|
|
21
|
+
} else {
|
|
22
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = savedEnv;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function tempDb(): string {
|
|
27
|
+
const dbPath = mkdtempSync(join(tmpdir(), "recallnest-read-consistency-"));
|
|
28
|
+
cleanupPaths.push(dbPath);
|
|
29
|
+
return dbPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function entry(i: number) {
|
|
33
|
+
return {
|
|
34
|
+
text: `read-consistency memory ${i}`,
|
|
35
|
+
vector: [0.1 * i, 0.2 * i, 0.3 * i],
|
|
36
|
+
category: "fact" as const,
|
|
37
|
+
scope: "project:rci-test",
|
|
38
|
+
importance: 0.5,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A writer always sees its own commits on its own handle; the staleness gap
|
|
43
|
+
// only exists ACROSS handles. Two independent MemoryStore instances against
|
|
44
|
+
// the same dbPath reproduce the cross-process topology (CLI ingest vs resident
|
|
45
|
+
// MCP/API/UI server) in-process: the writer deletes a row after the reader's
|
|
46
|
+
// handle has already opened the table.
|
|
47
|
+
describe("read consistency across independent connections", () => {
|
|
48
|
+
it("default (env unset) is strong consistency: reader sees the writer's delete", async () => {
|
|
49
|
+
delete process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL;
|
|
50
|
+
const dbPath = tempDb();
|
|
51
|
+
|
|
52
|
+
const writer = new MemoryStore({ dbPath, vectorDim: 3 });
|
|
53
|
+
const e1 = await writer.store(entry(1));
|
|
54
|
+
await writer.store(entry(2));
|
|
55
|
+
|
|
56
|
+
const reader = new MemoryStore({ dbPath, vectorDim: 3 });
|
|
57
|
+
expect((await reader.list(undefined, undefined, 20, 0)).length).toBe(2);
|
|
58
|
+
|
|
59
|
+
await writer.delete(e1.id);
|
|
60
|
+
|
|
61
|
+
expect((await reader.list(undefined, undefined, 20, 0)).length).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("env 'off' restores the legacy pinned-handle behavior (negative control)", async () => {
|
|
65
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = "off";
|
|
66
|
+
const dbPath = tempDb();
|
|
67
|
+
|
|
68
|
+
const writer = new MemoryStore({ dbPath, vectorDim: 3 });
|
|
69
|
+
const e1 = await writer.store(entry(1));
|
|
70
|
+
await writer.store(entry(2));
|
|
71
|
+
|
|
72
|
+
const reader = new MemoryStore({ dbPath, vectorDim: 3 });
|
|
73
|
+
expect((await reader.list(undefined, undefined, 20, 0)).length).toBe(2);
|
|
74
|
+
|
|
75
|
+
await writer.delete(e1.id);
|
|
76
|
+
|
|
77
|
+
expect((await reader.list(undefined, undefined, 20, 0)).length).toBe(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("explicit config wins over env: interval 0 sees the delete despite env 'off'", async () => {
|
|
81
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = "off";
|
|
82
|
+
const dbPath = tempDb();
|
|
83
|
+
|
|
84
|
+
const writer = new MemoryStore({ dbPath, vectorDim: 3 });
|
|
85
|
+
const e1 = await writer.store(entry(1));
|
|
86
|
+
await writer.store(entry(2));
|
|
87
|
+
|
|
88
|
+
const reader = new MemoryStore({ dbPath, vectorDim: 3, readConsistencyInterval: 0 });
|
|
89
|
+
expect((await reader.list(undefined, undefined, 20, 0)).length).toBe(2);
|
|
90
|
+
|
|
91
|
+
await writer.delete(e1.id);
|
|
92
|
+
|
|
93
|
+
expect((await reader.list(undefined, undefined, 20, 0)).length).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Cross-handle visibility behavior is pinned by the three tests above;
|
|
97
|
+
// what remains is the env parsing matrix of the resolver itself.
|
|
98
|
+
it("env resolver: unset/empty → 0, off/none → undefined, numbers pass, junk clamps to 0", () => {
|
|
99
|
+
delete process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL;
|
|
100
|
+
expect(envReadConsistencyInterval()).toBe(0);
|
|
101
|
+
|
|
102
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = "";
|
|
103
|
+
expect(envReadConsistencyInterval()).toBe(0);
|
|
104
|
+
|
|
105
|
+
for (const legacy of ["off", "none"]) {
|
|
106
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = legacy;
|
|
107
|
+
expect(envReadConsistencyInterval()).toBeUndefined();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = "7";
|
|
111
|
+
expect(envReadConsistencyInterval()).toBe(7);
|
|
112
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = "1.5";
|
|
113
|
+
expect(envReadConsistencyInterval()).toBe(1.5);
|
|
114
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = "0";
|
|
115
|
+
expect(envReadConsistencyInterval()).toBe(0);
|
|
116
|
+
|
|
117
|
+
for (const junk of ["-3", "abc", "Infinity", "NaN"]) {
|
|
118
|
+
process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL = junk;
|
|
119
|
+
expect(envReadConsistencyInterval()).toBe(0);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import * as lancedb from "@lancedb/lancedb";
|
|
6
|
+
|
|
7
|
+
import { MemoryStore } from "../store.js";
|
|
8
|
+
|
|
9
|
+
const DIM = 4;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Regression: the pre-2026-04-09 table schema has 8 columns (no language /
|
|
13
|
+
* fts_text). The old migration check sampled a row to detect missing columns,
|
|
14
|
+
* so a 0-row legacy table never migrated and EVERY write then failed with
|
|
15
|
+
* "Found field not in schema: language". Production hit exactly this: a table
|
|
16
|
+
* created one day before the columns existed, empty for 3 months.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
async function createLegacyTable(dbPath: string, withRow: boolean) {
|
|
20
|
+
const db = await lancedb.connect(dbPath);
|
|
21
|
+
const seed = [
|
|
22
|
+
{
|
|
23
|
+
id: "__seed__",
|
|
24
|
+
text: withRow ? "legacy row" : "",
|
|
25
|
+
vector: new Array(DIM).fill(0),
|
|
26
|
+
category: "other",
|
|
27
|
+
scope: "s",
|
|
28
|
+
importance: 0.5,
|
|
29
|
+
timestamp: 1,
|
|
30
|
+
metadata: "{}",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
const t = await db.createTable("memories", seed);
|
|
34
|
+
if (!withRow) await t.delete('id = "__seed__"');
|
|
35
|
+
return t;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("MemoryStore schema migration (empty legacy table)", () => {
|
|
39
|
+
let dir = "";
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
42
|
+
dir = "";
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("migrates an EMPTY legacy table so writes succeed", async () => {
|
|
46
|
+
dir = await mkdtemp(join(tmpdir(), "rn-mig-empty-"));
|
|
47
|
+
await createLegacyTable(dir, false);
|
|
48
|
+
|
|
49
|
+
const store = new MemoryStore({ dbPath: dir, vectorDim: DIM });
|
|
50
|
+
const entry = await store.store({
|
|
51
|
+
text: "first write after migration",
|
|
52
|
+
vector: new Array(DIM).fill(0.1),
|
|
53
|
+
category: "other",
|
|
54
|
+
scope: "s",
|
|
55
|
+
importance: 0.5,
|
|
56
|
+
});
|
|
57
|
+
expect(entry.id).toBeTruthy();
|
|
58
|
+
|
|
59
|
+
const db = await lancedb.connect(dir);
|
|
60
|
+
const t = await db.openTable("memories");
|
|
61
|
+
const fields = (await t.schema()).fields.map((f) => f.name);
|
|
62
|
+
expect(fields).toContain("language");
|
|
63
|
+
expect(fields).toContain("fts_text");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("migrates a NON-EMPTY legacy table and backfills defaults", async () => {
|
|
67
|
+
dir = await mkdtemp(join(tmpdir(), "rn-mig-full-"));
|
|
68
|
+
await createLegacyTable(dir, true);
|
|
69
|
+
|
|
70
|
+
const store = new MemoryStore({ dbPath: dir, vectorDim: DIM });
|
|
71
|
+
await store.store({
|
|
72
|
+
text: "second row",
|
|
73
|
+
vector: new Array(DIM).fill(0.2),
|
|
74
|
+
category: "other",
|
|
75
|
+
scope: "s",
|
|
76
|
+
importance: 0.5,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const db = await lancedb.connect(dir);
|
|
80
|
+
const t = await db.openTable("memories");
|
|
81
|
+
const rows = await t.query().toArray();
|
|
82
|
+
expect(rows).toHaveLength(2);
|
|
83
|
+
const legacy = rows.find((r) => r.id === "__seed__") as any;
|
|
84
|
+
expect(legacy.language).toBe("en");
|
|
85
|
+
expect(legacy.fts_text).toBe("legacy row");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -182,3 +182,20 @@ describe("store P0-2 idempotency (upsert-backed store/storeBatch)", () => {
|
|
|
182
182
|
expect(rows.filter((r) => r.text === "batch-x" || r.text === "batch-y").length).toBe(2);
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
describe("update() vector fallback (2026-07-23 null-vector regression)", () => {
|
|
187
|
+
it("metadata-only update preserves the original vector on healthy rows", async () => {
|
|
188
|
+
const store = makeStore();
|
|
189
|
+
const scope = "project:nullvec-test";
|
|
190
|
+
const entry = await store.store({
|
|
191
|
+
text: "健康行", vector: [0.1, 0.2, 0.3], category: "events", scope, importance: 0.7, metadata: "{}",
|
|
192
|
+
});
|
|
193
|
+
const updated = await store.update(entry.id, { metadata: JSON.stringify({ touched: true }) });
|
|
194
|
+
expect(updated).not.toBeNull();
|
|
195
|
+
// 修复 row.vector null-guard 后,正常行的 vector 必须原样保留(回归保护)
|
|
196
|
+
const rows = await store.list([scope]);
|
|
197
|
+
expect(rows.length).toBe(1);
|
|
198
|
+
const meta = JSON.parse(rows[0].metadata || "{}") as Record<string, unknown>;
|
|
199
|
+
expect(meta.touched).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { selectStableResults } from "../context-composer-stable-selection.js";
|
|
4
|
+
import { selectTaskResults } from "../context-composer-task-selection.js";
|
|
5
|
+
import {
|
|
6
|
+
PREFERENCE_SPECIFICITY_GROUPS,
|
|
7
|
+
TASK_RESULT_SPECIFICITY_GROUPS,
|
|
8
|
+
} from "../term-registry.js";
|
|
9
|
+
import type { RetrievalResult } from "../retriever.js";
|
|
10
|
+
|
|
11
|
+
// 端清单在检索术语簇里的落地。守两件事:
|
|
12
|
+
// ① 补上的端名真的解除了过滤(行为层)
|
|
13
|
+
// ② **有意的不对称不许被"机械补齐"抹掉**(结构层)——
|
|
14
|
+
// taskTerms 补端名、resultTerms 不补。这条最容易被后来人当成漏改而"顺手统一",
|
|
15
|
+
// 所以用测试钉住,并把理由写在断言旁边。
|
|
16
|
+
// 背景同 memory-boundaries.ts:64-70 的 TRANSCRIPT_SOURCES —— 那处是字面 scope 前缀层,
|
|
17
|
+
// 按字面补;这里是检索语义层,两侧方向相反,不能照抄。
|
|
18
|
+
|
|
19
|
+
function stableCandidate(text: string): RetrievalResult {
|
|
20
|
+
return {
|
|
21
|
+
entry: {
|
|
22
|
+
id: `test-${text.slice(0, 8)}`,
|
|
23
|
+
text,
|
|
24
|
+
vector: [],
|
|
25
|
+
category: "preferences",
|
|
26
|
+
scope: "memory:pivot",
|
|
27
|
+
importance: 0.8,
|
|
28
|
+
timestamp: Date.now(),
|
|
29
|
+
},
|
|
30
|
+
score: 1,
|
|
31
|
+
sources: {},
|
|
32
|
+
} as RetrievalResult;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function taskCandidate(text: string): RetrievalResult {
|
|
36
|
+
return {
|
|
37
|
+
entry: {
|
|
38
|
+
id: `test-${text.slice(0, 8)}`,
|
|
39
|
+
text,
|
|
40
|
+
vector: [],
|
|
41
|
+
category: "cases",
|
|
42
|
+
scope: "memory:pivot",
|
|
43
|
+
importance: 0.8,
|
|
44
|
+
timestamp: Date.now(),
|
|
45
|
+
},
|
|
46
|
+
score: 1,
|
|
47
|
+
sources: {},
|
|
48
|
+
} as RetrievalResult;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 会被 PREFERENCE_SPECIFICITY_GROUPS 认领的偏好(正文含 "codex" + "独立验证")
|
|
52
|
+
const CODEX_REVIEW_PREF =
|
|
53
|
+
"用户要求互审时由 Codex 做独立验证,交接材料里客观证据自动抽取,判断性材料必须手写填充。";
|
|
54
|
+
|
|
55
|
+
describe("端清单 · PREFERENCE_SPECIFICITY_GROUPS", () => {
|
|
56
|
+
it("问 kimi / agy / antigravity 时不再把 CC·Codex 的验收偏好滤掉", () => {
|
|
57
|
+
for (const task of [
|
|
58
|
+
"kimi 互审的时候要注意什么",
|
|
59
|
+
"agy 那边是谁在做审查",
|
|
60
|
+
"antigravity 的会话是怎么接进来的",
|
|
61
|
+
]) {
|
|
62
|
+
expect(
|
|
63
|
+
selectStableResults("preferences", [stableCandidate(CODEX_REVIEW_PREF)], 3, { taskSeed: task }),
|
|
64
|
+
).toHaveLength(1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("问端拓扑本身(三端 / 四端)时同样解除", () => {
|
|
69
|
+
for (const task of ["四端的触发规则都装好了吗", "三端现在还一致吗"]) {
|
|
70
|
+
expect(
|
|
71
|
+
selectStableResults("preferences", [stableCandidate(CODEX_REVIEW_PREF)], 3, { taskSeed: task }),
|
|
72
|
+
).toHaveLength(1);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("与端无关的任务仍然滤掉 —— 放宽不能变成不过滤", () => {
|
|
77
|
+
for (const task of ["帮我写一篇公众号文章", "百度网盘上传断链怎么排查"]) {
|
|
78
|
+
expect(
|
|
79
|
+
selectStableResults("preferences", [stableCandidate(CODEX_REVIEW_PREF)], 3, { taskSeed: task }),
|
|
80
|
+
).toHaveLength(0);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("resultTerms 有意不含端名 —— 别照着 taskTerms 补齐", () => {
|
|
85
|
+
// resultTerms 是「谁被这组认领」,加裸端名会把"顺带提了一句 kimi"的无关偏好
|
|
86
|
+
// 整条变成可滤对象。已知实例:裸词 "codex" 已因 `.codex/AGENTS.md` 这种
|
|
87
|
+
// 路径片段误认领记忆(shadow 实测 b8731a81)。再加端名 = 把缺陷复制三份。
|
|
88
|
+
for (const term of ["kimi", "antigravity", "agy", "三端", "四端"]) {
|
|
89
|
+
expect(PREFERENCE_SPECIFICITY_GROUPS[0]?.resultTerms).not.toContain(term);
|
|
90
|
+
}
|
|
91
|
+
expect(PREFERENCE_SPECIFICITY_GROUPS[0]?.taskTerms).toEqual(
|
|
92
|
+
expect.arrayContaining(["kimi", "antigravity", "agy", "三端", "四端"]),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("端清单 · 三端历史事件组(TASK_RESULT_SPECIFICITY_GROUPS)", () => {
|
|
98
|
+
const historyGroup = TASK_RESULT_SPECIFICITY_GROUPS.find((group) =>
|
|
99
|
+
group.resultTerms.includes("three-terminal continuity trigger validation"),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
it("resultTerms 是历史专名,不是端清单 —— 不补任何端名", () => {
|
|
103
|
+
expect(historyGroup).toBeDefined();
|
|
104
|
+
for (const term of ["kimi", "antigravity", "agy"]) {
|
|
105
|
+
expect(historyGroup?.resultTerms).not.toContain(term);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("taskTerms 补她真实的问法(三端 / 四端),但同样不补端名", () => {
|
|
110
|
+
// "三端" 不是 "三终端" 的子串,补之前真的匹配不上
|
|
111
|
+
expect(historyGroup?.taskTerms).toEqual(expect.arrayContaining(["三端", "四端"]));
|
|
112
|
+
for (const term of ["kimi", "antigravity", "agy"]) {
|
|
113
|
+
expect(historyGroup?.taskTerms).not.toContain(term);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("只提端名、不问触发/接入的任务,不该把这条三端历史放进来", () => {
|
|
118
|
+
const history = taskCandidate(
|
|
119
|
+
"三端连续性触发验证:three-terminal continuity trigger validation 已完成,continue-style prompts triggered recall reliably。",
|
|
120
|
+
);
|
|
121
|
+
expect(selectTaskResults("cases", [history], 3, { taskSeed: "kimi 挂了" })).toHaveLength(0);
|
|
122
|
+
// 对照:真正相关的问法照旧放行
|
|
123
|
+
expect(
|
|
124
|
+
selectTaskResults("cases", [history], 3, { taskSeed: "三端的触发验证结论还成立吗" }).length,
|
|
125
|
+
).toBeGreaterThan(0);
|
|
126
|
+
});
|
|
127
|
+
});
|