recallnest 2.5.4 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +34 -9
  3. package/README_CN.md +37 -12
  4. package/package.json +4 -4
  5. package/src/__tests__/access-tracker-novelty.test.ts +66 -0
  6. package/src/__tests__/api-server-security.test.ts +102 -0
  7. package/src/__tests__/belief-history.test.ts +220 -0
  8. package/src/__tests__/capture-engine.test.ts +93 -3
  9. package/src/__tests__/category-min-scores.test.ts +83 -0
  10. package/src/__tests__/cluster-consolidation.test.ts +167 -4
  11. package/src/__tests__/conflict-store.test.ts +46 -2
  12. package/src/__tests__/consolidation-engine.test.ts +97 -0
  13. package/src/__tests__/contracts.test.ts +46 -0
  14. package/src/__tests__/decay-exemption.test.ts +45 -0
  15. package/src/__tests__/dream-pipeline.test.ts +464 -5
  16. package/src/__tests__/freshness.test.ts +186 -0
  17. package/src/__tests__/hotness-blend.test.ts +207 -0
  18. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  19. package/src/__tests__/maintenance-locks.test.ts +15 -0
  20. package/src/__tests__/memory-boundaries.test.ts +32 -0
  21. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  22. package/src/__tests__/pii-redact.test.ts +19 -0
  23. package/src/__tests__/pivot-apply.test.ts +373 -0
  24. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  25. package/src/__tests__/pivot-distill.test.ts +1178 -0
  26. package/src/__tests__/preference-matcher.test.ts +54 -2
  27. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  28. package/src/__tests__/recall-gate.test.ts +266 -0
  29. package/src/__tests__/resolve-db-path.test.ts +130 -0
  30. package/src/__tests__/resolve-tier.test.ts +64 -0
  31. package/src/__tests__/retrieval-eval.test.ts +163 -0
  32. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  33. package/src/__tests__/skill-promotion.test.ts +53 -0
  34. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  35. package/src/__tests__/store-read-consistency.test.ts +122 -0
  36. package/src/__tests__/store-schema-migration.test.ts +87 -0
  37. package/src/__tests__/store-upsert.test.ts +17 -0
  38. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  39. package/src/__tests__/ui-server-security.test.ts +93 -0
  40. package/src/access-tracker.ts +53 -3
  41. package/src/adaptive-retrieval.ts +11 -0
  42. package/src/api-server.ts +9 -2
  43. package/src/auto-recall.ts +61 -1
  44. package/src/belief-history.ts +164 -0
  45. package/src/capture-engine.ts +88 -11
  46. package/src/cli.ts +109 -14
  47. package/src/conflict-engine.ts +24 -3
  48. package/src/consolidation-engine.ts +146 -12
  49. package/src/context-composer-stable-selection.ts +7 -1
  50. package/src/data-checkup.ts +1 -1
  51. package/src/decay-engine.ts +59 -7
  52. package/src/doctor.ts +4 -2
  53. package/src/dream-pipeline.ts +359 -11
  54. package/src/env-config.ts +41 -0
  55. package/src/eval.ts +154 -11
  56. package/src/freshness.ts +192 -0
  57. package/src/http-request-policy.ts +148 -0
  58. package/src/ingest.ts +277 -2
  59. package/src/kg-extractor.ts +7 -1
  60. package/src/llm-client.ts +106 -11
  61. package/src/mcp-server.ts +2 -1
  62. package/src/mcp-tools-advanced.ts +13 -0
  63. package/src/mcp-tools-core.ts +5 -1
  64. package/src/memory-boundaries.ts +23 -2
  65. package/src/memory-health-rebalance.ts +4 -0
  66. package/src/memory-output.ts +16 -2
  67. package/src/memory-store-port.ts +3 -0
  68. package/src/noise-prototype-bank.ts +408 -0
  69. package/src/pii-detector.ts +52 -2
  70. package/src/pivot-apply.ts +399 -0
  71. package/src/preference-matcher.ts +37 -3
  72. package/src/recall-gate.ts +305 -0
  73. package/src/retrieval-profiles.ts +46 -4
  74. package/src/retriever.ts +95 -18
  75. package/src/retroactive-boost.ts +6 -2
  76. package/src/runtime-config.ts +70 -5
  77. package/src/skill-promotion.ts +58 -2
  78. package/src/store.ts +54 -19
  79. package/src/term-registry.ts +35 -0
  80. package/src/ui-server.ts +7 -0
  81. package/src/version.ts +19 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Freshness 四态判定测试 —— 通用Wiki上下文记忆Agent方案借鉴落地(2026-07-21)。
3
+ * 覆盖 file / git-rev 两类依赖的 exact/compatible/uncertain/invalid 四态,
4
+ * 以及 opt-in 零回退(无 dependsOn → null)与共享缓存的 memoize。
5
+ */
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { execFileSync } from "node:child_process";
9
+ import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ createFreshnessCache,
14
+ evaluateDependency,
15
+ evaluateEntryFreshness,
16
+ evaluateFreshness,
17
+ parseDependsOn,
18
+ parseDependsOnInput,
19
+ type Dependency,
20
+ } from "../freshness.js";
21
+
22
+ let tmp: string;
23
+ let filePath: string;
24
+ let fileMtime: string;
25
+ let notGitDir: string;
26
+ let gitRepo: string;
27
+ let gitHead: string;
28
+
29
+ beforeAll(() => {
30
+ tmp = mkdtempSync(join(tmpdir(), "rn-freshness-"));
31
+
32
+ // 真实文件,供 kind='file' 用例
33
+ filePath = join(tmp, "dep.txt");
34
+ writeFileSync(filePath, "hello");
35
+ fileMtime = String(Math.floor(statSync(filePath).mtimeMs));
36
+
37
+ // 明确的非 git 目录(父级 tmpdir 也非 git),供 invalid 用例
38
+ notGitDir = join(tmp, "notgit");
39
+ mkdirSync(notGitDir, { recursive: true });
40
+
41
+ // 真实 git 仓库,供 kind='git-rev' 用例
42
+ gitRepo = join(tmp, "repo");
43
+ mkdirSync(gitRepo, { recursive: true });
44
+ const git = (args: string[]): void => {
45
+ execFileSync("git", ["-C", gitRepo, ...args], { stdio: ["ignore", "pipe", "ignore"] });
46
+ };
47
+ git(["init", "-q"]);
48
+ git(["config", "user.email", "t@example.com"]);
49
+ git(["config", "user.name", "tester"]);
50
+ writeFileSync(join(gitRepo, "f.txt"), "x");
51
+ git(["add", "."]);
52
+ git(["commit", "-q", "-m", "init"]);
53
+ gitHead = execFileSync("git", ["-C", gitRepo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
54
+ });
55
+
56
+ afterAll(() => {
57
+ rmSync(tmp, { recursive: true, force: true });
58
+ });
59
+
60
+ describe("parseDependsOn / parseDependsOnInput", () => {
61
+ test("无 dependsOn 的 metadata → null(opt-in 零回退)", () => {
62
+ expect(parseDependsOn(undefined)).toBeNull();
63
+ expect(parseDependsOn("{}")).toBeNull();
64
+ expect(parseDependsOn(JSON.stringify({ emotion: { valence: 0, arousal: 0 } }))).toBeNull();
65
+ });
66
+
67
+ test("非法 JSON / 非法结构 → null", () => {
68
+ expect(parseDependsOn("{not json")).toBeNull();
69
+ expect(parseDependsOn(JSON.stringify({ dependsOn: "nope" }))).toBeNull();
70
+ expect(parseDependsOn(JSON.stringify({ dependsOn: [{ kind: "bogus", ref: "x" }] }))).toBeNull();
71
+ });
72
+
73
+ test("合法 dependsOn → 解析出数组", () => {
74
+ const md = JSON.stringify({ dependsOn: [{ kind: "file", ref: "/a" }] });
75
+ const deps = parseDependsOn(md);
76
+ expect(deps).not.toBeNull();
77
+ expect(deps?.length).toBe(1);
78
+ expect(deps?.[0].kind).toBe("file");
79
+ });
80
+
81
+ test("parseDependsOnInput 校验原始输入", () => {
82
+ expect(parseDependsOnInput(null)).toBeNull();
83
+ expect(parseDependsOnInput([{ kind: "file", ref: "/a" }])?.length).toBe(1);
84
+ expect(parseDependsOnInput([{ kind: "x", ref: "/a" }])).toBeNull();
85
+ expect(parseDependsOnInput([])).toBeNull(); // min(1)
86
+ });
87
+ });
88
+
89
+ describe("file 依赖四态", () => {
90
+ const cache = createFreshnessCache();
91
+
92
+ test("存在 + 无 expected → exact", () => {
93
+ expect(evaluateDependency({ kind: "file", ref: filePath }, cache)).toBe("exact");
94
+ });
95
+
96
+ test("存在 + mtime 匹配 → exact", () => {
97
+ expect(evaluateDependency({ kind: "file", ref: filePath, expected: fileMtime }, cache)).toBe("exact");
98
+ });
99
+
100
+ test("存在 + mtime 落在兼容集次值 → compatible", () => {
101
+ expect(
102
+ evaluateDependency({ kind: "file", ref: filePath, expected: ["9999999999999", fileMtime] }, cache),
103
+ ).toBe("compatible");
104
+ });
105
+
106
+ test("存在 + mtime 不匹配 → uncertain", () => {
107
+ expect(evaluateDependency({ kind: "file", ref: filePath, expected: "1" }, cache)).toBe("uncertain");
108
+ });
109
+
110
+ test("文件不存在 → invalid", () => {
111
+ expect(evaluateDependency({ kind: "file", ref: join(tmp, "nope.txt") }, cache)).toBe("invalid");
112
+ });
113
+ });
114
+
115
+ describe("git-rev 依赖四态", () => {
116
+ const cache = createFreshnessCache();
117
+
118
+ test("HEAD 匹配(完整 hash)→ exact", () => {
119
+ expect(evaluateDependency({ kind: "git-rev", ref: gitRepo, expected: gitHead }, cache)).toBe("exact");
120
+ });
121
+
122
+ test("HEAD 匹配(短 hash 前缀)→ exact", () => {
123
+ expect(evaluateDependency({ kind: "git-rev", ref: gitRepo, expected: gitHead.slice(0, 8) }, cache)).toBe("exact");
124
+ });
125
+
126
+ test("HEAD 落在兼容集次值 → compatible", () => {
127
+ expect(
128
+ evaluateDependency({ kind: "git-rev", ref: gitRepo, expected: ["deadbeefcafe1234", gitHead] }, cache),
129
+ ).toBe("compatible");
130
+ });
131
+
132
+ test("HEAD 不匹配 → uncertain", () => {
133
+ expect(evaluateDependency({ kind: "git-rev", ref: gitRepo, expected: "deadbeefcafe1234" }, cache)).toBe("uncertain");
134
+ });
135
+
136
+ test("仓库内文件 + 无 expected → exact(仅存在性)", () => {
137
+ expect(evaluateDependency({ kind: "git-rev", ref: join(gitRepo, "f.txt") }, cache)).toBe("exact");
138
+ });
139
+
140
+ test("非 git 目录 → invalid", () => {
141
+ expect(evaluateDependency({ kind: "git-rev", ref: notGitDir, expected: gitHead }, cache)).toBe("invalid");
142
+ });
143
+ });
144
+
145
+ describe("evaluateFreshness 聚合 + evaluateEntryFreshness 入口", () => {
146
+ test("多依赖取最差:exact + invalid → invalid", () => {
147
+ const deps: Dependency[] = [
148
+ { kind: "file", ref: filePath },
149
+ { kind: "file", ref: join(tmp, "gone.txt") },
150
+ ];
151
+ expect(evaluateFreshness(deps)).toBe("invalid");
152
+ });
153
+
154
+ test("多依赖取最差:exact + compatible → compatible", () => {
155
+ const deps: Dependency[] = [
156
+ { kind: "file", ref: filePath },
157
+ { kind: "git-rev", ref: gitRepo, expected: ["deadbeefcafe1234", gitHead] },
158
+ ];
159
+ expect(evaluateFreshness(deps)).toBe("compatible");
160
+ });
161
+
162
+ test("空依赖 → exact", () => {
163
+ expect(evaluateFreshness([])).toBe("exact");
164
+ });
165
+
166
+ test("evaluateEntryFreshness: 无 dependsOn → null(零回退)", () => {
167
+ expect(evaluateEntryFreshness(undefined)).toBeNull();
168
+ expect(evaluateEntryFreshness("{}")).toBeNull();
169
+ });
170
+
171
+ test("evaluateEntryFreshness: 有 dependsOn → 对应四态", () => {
172
+ const exactMd = JSON.stringify({ dependsOn: [{ kind: "file", ref: filePath, expected: fileMtime }] });
173
+ expect(evaluateEntryFreshness(exactMd)).toBe("exact");
174
+ const invalidMd = JSON.stringify({ dependsOn: [{ kind: "file", ref: join(tmp, "gone.txt") }] });
175
+ expect(evaluateEntryFreshness(invalidMd)).toBe("invalid");
176
+ });
177
+
178
+ test("共享缓存 memoize:同一仓库多依赖只查一次 git", () => {
179
+ const cache = createFreshnessCache();
180
+ evaluateDependency({ kind: "git-rev", ref: gitRepo, expected: gitHead }, cache);
181
+ expect(cache.size).toBe(1);
182
+ // f.txt 位于 gitRepo 内,dirname=gitRepo,命中同一缓存键
183
+ evaluateDependency({ kind: "git-rev", ref: join(gitRepo, "f.txt"), expected: gitHead }, cache);
184
+ expect(cache.size).toBe(1);
185
+ });
186
+ });
@@ -1,5 +1,34 @@
1
1
  import { describe, it, expect } from "bun:test";
2
2
  import { computeHotnessScore, parseAccessMetadata } from "../access-tracker.js";
3
+ import { TraceCollector } from "../retrieval-trace.js";
4
+ import {
5
+ createRetriever,
6
+ DEFAULT_CATEGORY_MIN_SCORES,
7
+ } from "../retriever.js";
8
+
9
+ function applyHotnessBlend(
10
+ score: number,
11
+ accessCount: number,
12
+ lastAccessedAt: number,
13
+ ): number {
14
+ const retriever = createRetriever({} as any, {} as any, { hotnessWeight: 0.15 });
15
+ retriever.setAccessTracker({} as any);
16
+ const results = [{
17
+ entry: {
18
+ id: "hotness-test",
19
+ text: "hotness test",
20
+ vector: [],
21
+ category: "events",
22
+ scope: "test:hotness",
23
+ importance: 0.5,
24
+ timestamp: Date.now(),
25
+ metadata: JSON.stringify({ accessCount, lastAccessedAt }),
26
+ },
27
+ score,
28
+ sources: {},
29
+ }];
30
+ return (retriever as any).applyHotnessBlend(results)[0].score;
31
+ }
3
32
 
4
33
  describe("computeHotnessScore", () => {
5
34
  it("returns 0 for zero accesses", () => {
@@ -74,3 +103,181 @@ describe("parseAccessMetadata", () => {
74
103
  expect(result.lastAccessedAt).toBe(0);
75
104
  });
76
105
  });
106
+
107
+ describe("applyHotnessBlend", () => {
108
+ it("does not penalize a zero-access row", () => {
109
+ expect(applyHotnessBlend(0.6, 0, 0)).toBe(0.6);
110
+ });
111
+
112
+ it("preserves the existing blend formula for an accessed row", () => {
113
+ const futureAccess = Date.now() + 60_000;
114
+ const hotness = computeHotnessScore(5, futureAccess);
115
+ expect(applyHotnessBlend(0.6, 5, futureAccess)).toBeCloseTo(
116
+ 0.6 * 0.85 + hotness * 0.15,
117
+ 12,
118
+ );
119
+ });
120
+
121
+ it("still applies the existing penalty when an accessed row's hotness decays to zero", () => {
122
+ expect(computeHotnessScore(1, 1)).toBe(0);
123
+ expect(applyHotnessBlend(0.6, 1, 1)).toBeCloseTo(0.6 * 0.85, 12);
124
+ });
125
+ });
126
+
127
+ describe("cold-start hotness pipeline regression", () => {
128
+ it("recalls a78ad478 without dropping control dd05b422", async () => {
129
+ const query = "owner 模型 任务锁 并发 谁在干活 责任层 资源层";
130
+ const now = Date.now();
131
+ const text = `${query} `.repeat(80).slice(0, 1900);
132
+
133
+ expect(text).toHaveLength(1900);
134
+
135
+ function metadata(
136
+ source: "agent" | "manual",
137
+ confidence: number,
138
+ accessCount: number,
139
+ ): string {
140
+ const lastAccessedAt = accessCount > 0 ? now : 0;
141
+
142
+ return JSON.stringify({
143
+ source,
144
+ confidence: {
145
+ score: confidence,
146
+ reliability: source === "manual" ? "direct" : "inferred",
147
+ },
148
+ accessCount,
149
+ lastAccessedAt,
150
+ boundary: {
151
+ layer: "durable",
152
+ authority: "structured-memory",
153
+ conflictPolicy: "latest-wins",
154
+ originalCategory: "patterns",
155
+ },
156
+ evolution: {
157
+ status: "active",
158
+ version: 1,
159
+ accessCount,
160
+ lastAccessedAt: accessCount > 0 ? lastAccessedAt : null,
161
+ validFrom: now,
162
+ validUntil: null,
163
+ },
164
+ });
165
+ }
166
+
167
+ const candidates = [
168
+ {
169
+ entry: {
170
+ id: "a78ad478",
171
+ text,
172
+ vector: [1, 0, 0],
173
+ category: "patterns",
174
+ scope: "project:hippo-wiki",
175
+ importance: 0.8,
176
+ timestamp: now,
177
+ metadata: metadata("agent", 0.7, 0),
178
+ },
179
+ score: 0.8628,
180
+ },
181
+ {
182
+ entry: {
183
+ id: "dd05b422",
184
+ text,
185
+ vector: [0.99, 0.01, 0],
186
+ category: "patterns",
187
+ scope: "project:hippo-wiki",
188
+ importance: 0.8,
189
+ timestamp: now,
190
+ metadata: metadata("manual", 0.9, 4),
191
+ },
192
+ score: 0.8579,
193
+ },
194
+ ];
195
+
196
+ let vectorSearchCalls = 0;
197
+
198
+ const store = {
199
+ hasFtsSupport: false,
200
+ async vectorSearch(
201
+ _queryVector: number[],
202
+ limit: number,
203
+ minScore: number,
204
+ scopeFilter?: string[],
205
+ ) {
206
+ vectorSearchCalls += 1;
207
+ return candidates
208
+ .filter(candidate => candidate.score >= minScore)
209
+ .filter(candidate =>
210
+ !scopeFilter || scopeFilter.includes(candidate.entry.scope),
211
+ )
212
+ .slice(0, limit);
213
+ },
214
+ };
215
+
216
+ const embedder = {
217
+ async embedQuery() {
218
+ return [1, 0, 0];
219
+ },
220
+ async embedPassage() {
221
+ return [1, 0, 0];
222
+ },
223
+ };
224
+
225
+ const retriever = createRetriever(
226
+ store as any,
227
+ embedder as any,
228
+ {
229
+ mode: "vector",
230
+ rerank: "none",
231
+ candidatePoolSize: 5,
232
+ minScore: 0.3,
233
+ hardMinScore: 0.38,
234
+ recencyHalfLifeDays: 14,
235
+ recencyWeight: 0.15,
236
+ lengthNormAnchor: 700,
237
+ timeDecayHalfLifeDays: 60,
238
+ hotnessWeight: 0.15,
239
+ filterNoise: false,
240
+ enableRIF: false,
241
+ sourceDiversity: 0,
242
+ multiHop: false,
243
+ },
244
+ );
245
+
246
+ retriever.setAccessTracker({
247
+ computeEffectiveHalfLife(baseHalfLife: number) {
248
+ return baseHalfLife;
249
+ },
250
+ recordAccess() {
251
+ throw new Error("pipeline regression must not record access");
252
+ },
253
+ } as any);
254
+
255
+ const trace = new TraceCollector();
256
+ const results = await retriever.retrieve({
257
+ query,
258
+ limit: 5,
259
+ scopeFilter: ["project:hippo-wiki"],
260
+ category: "patterns",
261
+ source: "auto-recall",
262
+ trace,
263
+ });
264
+
265
+ const ids = results.map(result => result.entry.id);
266
+ const cold = results.find(result => result.entry.id === "a78ad478");
267
+ const hardMin = trace
268
+ .finalize(query, "vector")
269
+ .stages.find(stage => stage.name === "hard_min_score");
270
+
271
+ expect(ids).toContain("a78ad478");
272
+ expect(ids).toContain("dd05b422");
273
+ expect(cold?.score).toBeGreaterThanOrEqual(
274
+ DEFAULT_CATEGORY_MIN_SCORES.patterns,
275
+ );
276
+ expect(hardMin).toMatchObject({
277
+ inputCount: 2,
278
+ outputCount: 2,
279
+ droppedCount: 0,
280
+ });
281
+ expect(vectorSearchCalls).toBe(1);
282
+ });
283
+ });