recallnest 2.5.4 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +34 -9
  3. package/README_CN.md +37 -12
  4. package/package.json +6 -5
  5. package/src/__tests__/access-tracker-novelty.test.ts +66 -0
  6. package/src/__tests__/api-server-security.test.ts +102 -0
  7. package/src/__tests__/belief-history.test.ts +220 -0
  8. package/src/__tests__/capture-engine.test.ts +93 -3
  9. package/src/__tests__/category-min-scores.test.ts +83 -0
  10. package/src/__tests__/cluster-consolidation.test.ts +167 -4
  11. package/src/__tests__/conflict-store.test.ts +46 -2
  12. package/src/__tests__/consolidation-engine.test.ts +97 -0
  13. package/src/__tests__/contracts.test.ts +46 -0
  14. package/src/__tests__/decay-exemption.test.ts +45 -0
  15. package/src/__tests__/dream-pipeline.test.ts +464 -5
  16. package/src/__tests__/freshness.test.ts +186 -0
  17. package/src/__tests__/hotness-blend.test.ts +207 -0
  18. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  19. package/src/__tests__/maintenance-locks.test.ts +15 -0
  20. package/src/__tests__/memory-boundaries.test.ts +32 -0
  21. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  22. package/src/__tests__/pii-redact.test.ts +19 -0
  23. package/src/__tests__/pivot-apply.test.ts +373 -0
  24. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  25. package/src/__tests__/pivot-distill.test.ts +1178 -0
  26. package/src/__tests__/preference-matcher.test.ts +54 -2
  27. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  28. package/src/__tests__/recall-gate.test.ts +266 -0
  29. package/src/__tests__/resolve-db-path.test.ts +130 -0
  30. package/src/__tests__/resolve-tier.test.ts +64 -0
  31. package/src/__tests__/retrieval-eval.test.ts +163 -0
  32. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  33. package/src/__tests__/skill-promotion.test.ts +53 -0
  34. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  35. package/src/__tests__/store-read-consistency.test.ts +122 -0
  36. package/src/__tests__/store-schema-migration.test.ts +87 -0
  37. package/src/__tests__/store-upsert.test.ts +17 -0
  38. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  39. package/src/__tests__/ui-server-security.test.ts +93 -0
  40. package/src/access-tracker.ts +53 -3
  41. package/src/adaptive-retrieval.ts +11 -0
  42. package/src/api-server.ts +9 -2
  43. package/src/auto-recall.ts +61 -1
  44. package/src/belief-history.ts +164 -0
  45. package/src/capture-engine.ts +88 -11
  46. package/src/cli.ts +109 -14
  47. package/src/conflict-engine.ts +24 -3
  48. package/src/consolidation-engine.ts +146 -12
  49. package/src/context-composer-stable-selection.ts +7 -1
  50. package/src/data-checkup.ts +1 -1
  51. package/src/decay-engine.ts +59 -7
  52. package/src/doctor.ts +4 -2
  53. package/src/dream-pipeline.ts +359 -11
  54. package/src/env-config.ts +41 -0
  55. package/src/eval.ts +154 -11
  56. package/src/freshness.ts +192 -0
  57. package/src/http-request-policy.ts +148 -0
  58. package/src/ingest.ts +277 -2
  59. package/src/kg-extractor.ts +7 -1
  60. package/src/llm-client.ts +106 -11
  61. package/src/mcp-server.ts +2 -1
  62. package/src/mcp-tools-advanced.ts +13 -0
  63. package/src/mcp-tools-core.ts +5 -1
  64. package/src/memory-boundaries.ts +23 -2
  65. package/src/memory-health-rebalance.ts +4 -0
  66. package/src/memory-output.ts +16 -2
  67. package/src/memory-store-port.ts +3 -0
  68. package/src/noise-prototype-bank.ts +408 -0
  69. package/src/pii-detector.ts +52 -2
  70. package/src/pivot-apply.ts +399 -0
  71. package/src/preference-matcher.ts +37 -3
  72. package/src/recall-gate.ts +305 -0
  73. package/src/retrieval-profiles.ts +46 -4
  74. package/src/retriever.ts +95 -18
  75. package/src/retroactive-boost.ts +6 -2
  76. package/src/runtime-config.ts +70 -5
  77. package/src/skill-promotion.ts +58 -2
  78. package/src/store.ts +54 -19
  79. package/src/term-registry.ts +35 -0
  80. package/src/ui-server.ts +7 -0
  81. package/src/version.ts +19 -0
@@ -26,6 +26,38 @@ describe("memory boundaries", () => {
26
26
  expect(resolved.boundary.downgradedFrom).toBe("profile");
27
27
  });
28
28
 
29
+ // 端清单回归:memory-boundaries.ts 的 TRANSCRIPT_SOURCES / TRANSCRIPT_SCOPE_PREFIXES
30
+ // 历史上漏过两次(kimi 2026-07-19 进栈、antigravity 07-29 进栈,都到 08-01 才发现)。
31
+ // 根因不是"忘了改",是**没有断言**——纯判断步骤没有测试撑着就会被跳过。
32
+ // ⚠️ 接入新端时:先在下面的清单里加一行让测试变红,再去改常量把它变绿。
33
+ it.each([
34
+ ["cc", "cc:abc123"],
35
+ ["codex", "codex:abc123"],
36
+ ["gemini", "gemini:abc123"],
37
+ ["kimi", "kimi:abc123"],
38
+ ["antigravity", "antigravity:abc123"],
39
+ ["minis", "minis:abc123"],
40
+ ])("downgrades transcript profile facts for end %s", (source, scope) => {
41
+ const resolved = resolveIngestBoundary({ source, scope, category: "profile" });
42
+
43
+ expect(resolved.boundary.layer).toBe("evidence");
44
+ expect(resolved.boundary.authority).toBe("transcript-ingest");
45
+ expect(resolved.category).toBe("events");
46
+ });
47
+
48
+ // 反向断言:确认上面那组不是同义反复(如果任何 source 都被降权,那组测试就永远绿、
49
+ // 等于没测)。一个不在清单里的来源必须**不**被当成 transcript。
50
+ it("does not downgrade sources outside the transcript end list", () => {
51
+ const resolved = resolveIngestBoundary({
52
+ source: "manual",
53
+ scope: "memory:pivot",
54
+ category: "profile",
55
+ });
56
+
57
+ expect(resolved.boundary.layer).not.toBe("evidence");
58
+ expect(resolved.category).toBe("profile");
59
+ });
60
+
29
61
  it("keeps transcript cases searchable but marks them as evidence", () => {
30
62
  const resolved = resolveIngestBoundary({
31
63
  source: "codex",
@@ -0,0 +1,280 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { persistMemory } from "../capture-engine.js";
7
+ import {
8
+ NoisePrototypeBank,
9
+ isProtectedContent,
10
+ resetSharedNoisePrototypeBank,
11
+ resolveNoisePrototypeBank,
12
+ resolveNoisePrototypeMode,
13
+ } from "../noise-prototype-bank.js";
14
+
15
+ const cleanupPaths: string[] = [];
16
+
17
+ afterEach(() => {
18
+ resetSharedNoisePrototypeBank();
19
+ while (cleanupPaths.length > 0) {
20
+ const target = cleanupPaths.pop();
21
+ if (target) rmSync(target, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ function tempDir(): string {
26
+ const dir = mkdtempSync(join(tmpdir(), "recallnest-npb-"));
27
+ cleanupPaths.push(dir);
28
+ return dir;
29
+ }
30
+
31
+ function makeBank(overrides: Partial<ConstructorParameters<typeof NoisePrototypeBank>[0]> = {}) {
32
+ const dir = tempDir();
33
+ const bank = new NoisePrototypeBank({
34
+ filePath: join(dir, "bank.json"),
35
+ shadowLogPath: join(dir, "shadow.jsonl"),
36
+ ...overrides,
37
+ });
38
+ return { bank, dir };
39
+ }
40
+
41
+ // Deterministic fake embedder: unit vectors along an axis picked by seed.
42
+ function axisVector(axis: number, dim = 8): number[] {
43
+ const v = new Array(dim).fill(0);
44
+ v[axis] = 1;
45
+ return v;
46
+ }
47
+
48
+ const embedAxis0 = async () => axisVector(0);
49
+
50
+ describe("NoisePrototypeBank — learning discipline", () => {
51
+ it("first rejection is only a pending candidate; the repeat sighting learns", async () => {
52
+ const { bank } = makeBank();
53
+ expect(await bank.learnFromRejection("嗯嗯嗯嗯好的好的", embedAxis0)).toBe("candidate_seen");
54
+ expect(bank.list().length).toBe(0);
55
+ expect(bank.pendingCount()).toBe(1);
56
+
57
+ expect(await bank.learnFromRejection("嗯嗯嗯嗯好的好的", embedAxis0)).toBe("learned");
58
+ expect(bank.list().length).toBe(1);
59
+ expect(bank.pendingCount()).toBe(0);
60
+ expect(bank.list()[0]?.seenCount).toBe(2);
61
+ });
62
+
63
+ it("normalization dedupes near-identical rejections into one candidate", async () => {
64
+ const { bank } = makeBank();
65
+ await bank.learnFromRejection("嗯嗯嗯嗯好的好的", embedAxis0);
66
+ // Same content modulo whitespace/punctuation/case → same candidate.
67
+ expect(await bank.learnFromRejection("嗯嗯嗯嗯,好的 好的!!", embedAxis0)).toBe("learned");
68
+ });
69
+
70
+ it("already-learned content reports skipped_duplicate without re-embedding", async () => {
71
+ const { bank } = makeBank();
72
+ let embeds = 0;
73
+ const countingEmbed = async () => {
74
+ embeds += 1;
75
+ return axisVector(0);
76
+ };
77
+ await bank.learnFromRejection("嗯嗯嗯嗯好的好的", countingEmbed);
78
+ await bank.learnFromRejection("嗯嗯嗯嗯好的好的", countingEmbed);
79
+ expect(embeds).toBe(1);
80
+ expect(await bank.learnFromRejection("嗯嗯嗯嗯好的好的", countingEmbed)).toBe("skipped_duplicate");
81
+ expect(embeds).toBe(1);
82
+ });
83
+
84
+ it("protected content is never learned, even when the noise filter rejected it", async () => {
85
+ const { bank } = makeBank();
86
+ expect(await bank.learnFromRejection("记住我喜欢的部署方式", embedAxis0)).toBe("skipped_protected");
87
+ expect(await bank.learnFromRejection("记住我喜欢的部署方式", embedAxis0)).toBe("skipped_protected");
88
+ expect(bank.list().length).toBe(0);
89
+ expect(bank.pendingCount()).toBe(0);
90
+ });
91
+
92
+ it("the cap stops learning at maxPrototypes", async () => {
93
+ const { bank } = makeBank({ maxPrototypes: 1, minSeenCount: 1 });
94
+ expect(await bank.learnFromRejection("噪声内容甲甲甲甲", embedAxis0)).toBe("learned");
95
+ expect(await bank.learnFromRejection("噪声内容乙乙乙乙", embedAxis0)).toBe("skipped_full");
96
+ expect(bank.list().length).toBe(1);
97
+ });
98
+ });
99
+
100
+ describe("NoisePrototypeBank — shadow matching", () => {
101
+ async function seededBank() {
102
+ const made = makeBank({ minSeenCount: 1 });
103
+ await made.bank.learnFromRejection("嗯嗯嗯嗯好的好的", embedAxis0);
104
+ return made;
105
+ }
106
+
107
+ it("similarity at/above threshold suggests rejection and logs, but only logs", async () => {
108
+ const { bank, dir } = await seededBank();
109
+ const match = bank.matchShadow("嗯嗯好的收到啦", axisVector(0), "mem-123");
110
+ expect(match.matched).toBe(true);
111
+ expect(match.similarity).toBeCloseTo(1, 5);
112
+ expect(bank.list()[0]?.hitCount).toBe(1);
113
+
114
+ const log = readFileSync(join(dir, "shadow.jsonl"), "utf8").trim().split("\n");
115
+ const suggest = log.map((l) => JSON.parse(l)).find((e) => e.event === "suggest_reject");
116
+ expect(suggest?.memoryId).toBe("mem-123");
117
+ expect(suggest?.prototypeId).toBe(bank.list()[0]?.id);
118
+ expect(JSON.stringify(suggest)).not.toContain("嗯嗯好的收到啦");
119
+ });
120
+
121
+ it("below-threshold similarity does not match", async () => {
122
+ const { bank } = await seededBank();
123
+ const match = bank.matchShadow("完全不同的正交内容", axisVector(1));
124
+ expect(match.matched).toBe(false);
125
+ expect(bank.list()[0]?.hitCount).toBe(0);
126
+ });
127
+
128
+ it("protected content is never flagged regardless of similarity", async () => {
129
+ const { bank } = await seededBank();
130
+ const match = bank.matchShadow("记住我喜欢的部署方式", axisVector(0));
131
+ expect(match.matched).toBe(false);
132
+ expect(match.protected).toBe(true);
133
+ });
134
+
135
+ it("dimension mismatch degrades to no match instead of throwing", async () => {
136
+ const { bank } = await seededBank();
137
+ const match = bank.matchShadow("维度不齐的向量", [1, 0]);
138
+ expect(match.matched).toBe(false);
139
+ });
140
+ });
141
+
142
+ describe("NoisePrototypeBank — correction & lifecycle", () => {
143
+ it("removePrototype surgically deletes one false positive", async () => {
144
+ const { bank } = makeBank({ minSeenCount: 1 });
145
+ await bank.learnFromRejection("噪声内容甲甲甲甲", embedAxis0);
146
+ const id = bank.list()[0]?.id ?? "";
147
+ expect(bank.removePrototype(id)).toBe(true);
148
+ expect(bank.list().length).toBe(0);
149
+ expect(bank.removePrototype(id)).toBe(false);
150
+ });
151
+
152
+ it("clear resets everything", async () => {
153
+ const { bank } = makeBank({ minSeenCount: 1 });
154
+ await bank.learnFromRejection("噪声内容甲甲甲甲", embedAxis0);
155
+ bank.clear();
156
+ expect(bank.list().length).toBe(0);
157
+ });
158
+
159
+ it("state survives reconstruction from disk; expired prototypes are swept", async () => {
160
+ const { bank, dir } = makeBank({ minSeenCount: 1 });
161
+ await bank.learnFromRejection("噪声内容甲甲甲甲", embedAxis0);
162
+
163
+ const reloaded = new NoisePrototypeBank({
164
+ filePath: join(dir, "bank.json"),
165
+ shadowLogPath: join(dir, "shadow.jsonl"),
166
+ });
167
+ expect(reloaded.list().length).toBe(1);
168
+
169
+ // Age the stored prototype past the TTL, then reload.
170
+ const raw = JSON.parse(readFileSync(join(dir, "bank.json"), "utf8"));
171
+ raw.prototypes[0].learnedAt = "2020-01-01T00:00:00.000Z";
172
+ writeFileSync(join(dir, "bank.json"), JSON.stringify(raw), "utf8");
173
+ const expiring = new NoisePrototypeBank({
174
+ filePath: join(dir, "bank.json"),
175
+ shadowLogPath: join(dir, "shadow.jsonl"),
176
+ });
177
+ expect(expiring.list().length).toBe(0);
178
+ });
179
+ });
180
+
181
+ describe("isProtectedContent", () => {
182
+ it("covers memory verbs, corrections, decisions, identity, preferences", () => {
183
+ for (const text of ["记住这个", "帮我记一下", "纠正一下,不对", "我决定用方案B", "我是安闲静雅", "我喜欢深色主题", "i prefer dark mode", "remember this setup"]) {
184
+ expect(`${text}:${isProtectedContent(text)}`).toBe(`${text}:true`);
185
+ }
186
+ for (const text of ["嗯嗯好的", "git push", "今天天气不错"]) {
187
+ expect(`${text}:${isProtectedContent(text)}`).toBe(`${text}:false`);
188
+ }
189
+ });
190
+ });
191
+
192
+ describe("resolveNoisePrototypeMode / shared bank resolution", () => {
193
+ it("defaults to shadow in production, off under NODE_ENV=test unless explicit", () => {
194
+ expect(resolveNoisePrototypeMode({} as NodeJS.ProcessEnv)).toBe("shadow");
195
+ expect(resolveNoisePrototypeMode({ NODE_ENV: "test" } as NodeJS.ProcessEnv)).toBe("off");
196
+ expect(resolveNoisePrototypeMode({ NODE_ENV: "test", RECALLNEST_NOISE_PROTOTYPE: "shadow" } as NodeJS.ProcessEnv)).toBe("shadow");
197
+ expect(resolveNoisePrototypeMode({ RECALLNEST_NOISE_PROTOTYPE: "off" } as NodeJS.ProcessEnv)).toBe("off");
198
+ expect(resolveNoisePrototypeMode({ RECALLNEST_NOISE_PROTOTYPE: "banana" } as NodeJS.ProcessEnv)).toBe("shadow");
199
+ });
200
+
201
+ it("explicit deps win over the env-governed shared instance", () => {
202
+ const { bank } = makeBank();
203
+ expect(resolveNoisePrototypeBank(bank)).toBe(bank);
204
+ expect(resolveNoisePrototypeBank(null)).toBeNull();
205
+ // Under bun test (NODE_ENV=test, no explicit env) the shared bank is off.
206
+ expect(resolveNoisePrototypeBank(undefined)).toBeNull();
207
+ });
208
+ });
209
+
210
+ // --- integration through persistMemory ---
211
+
212
+ function makePersistDeps(bank: NoisePrototypeBank | null) {
213
+ const storeCalls: unknown[] = [];
214
+ return {
215
+ storeCalls,
216
+ deps: {
217
+ embedder: {
218
+ async embedPassage() {
219
+ return axisVector(0);
220
+ },
221
+ },
222
+ store: {
223
+ async store(entry: Record<string, unknown>) {
224
+ storeCalls.push(entry);
225
+ return { ...entry, id: `mem-${storeCalls.length}`, timestamp: Date.now() };
226
+ },
227
+ },
228
+ noisePrototypeBank: bank,
229
+ },
230
+ };
231
+ }
232
+
233
+ describe("persistMemory × noise-prototype shadow", () => {
234
+ it("noise_detected rejection feeds the bank; the write is still rejected", async () => {
235
+ const { bank } = makeBank({ minSeenCount: 1 });
236
+ const { deps } = makePersistDeps(bank);
237
+
238
+ const result = await persistMemory(deps as never, {
239
+ text: "hello world, how are you today?",
240
+ category: "events",
241
+ importance: 0.7,
242
+ scope: "project:npb-test",
243
+ source: "manual",
244
+ });
245
+
246
+ expect(result.disposition).toBe("rejected");
247
+ expect(result.rejectionReason).toBe("noise_detected");
248
+ expect(bank.list().length).toBe(1); // minSeenCount 1 → learned immediately
249
+ });
250
+
251
+ it("accepted writes run the shadow match against the write-path vector", async () => {
252
+ const { bank, dir } = makeBank({ minSeenCount: 1 });
253
+ await bank.learnFromRejection("噪声原型种子内容", embedAxis0);
254
+ const { deps } = makePersistDeps(bank);
255
+
256
+ const result = await persistMemory(deps as never, {
257
+ text: "RecallNest 的 readConsistencyInterval 已经默认为 0",
258
+ category: "events",
259
+ importance: 0.7,
260
+ scope: "project:npb-test",
261
+ source: "manual",
262
+ });
263
+
264
+ expect(result.disposition).not.toBe("rejected");
265
+ const log = readFileSync(join(dir, "shadow.jsonl"), "utf8");
266
+ expect(log).toContain("suggest_reject"); // axis-0 fake vectors always collide
267
+ });
268
+
269
+ it("bank=null disables both sides completely", async () => {
270
+ const { deps } = makePersistDeps(null);
271
+ const result = await persistMemory(deps as never, {
272
+ text: "hello world, how are you today?",
273
+ category: "events",
274
+ importance: 0.7,
275
+ scope: "project:npb-test",
276
+ source: "manual",
277
+ });
278
+ expect(result.disposition).toBe("rejected"); // admission unchanged
279
+ });
280
+ });
@@ -17,6 +17,25 @@ describe("redactSecrets", () => {
17
17
  expect(r.redacted).toBe(1);
18
18
  });
19
19
 
20
+ it("does not scrub the sk tail of hyphenated slugs (task-, risk-)", () => {
21
+ for (const slug of [
22
+ "pivot-decision-task-assignment-worker-stability-fix",
23
+ "pivot-judgment-shift-risk-gated-review-decision-process",
24
+ "note about task-ant-colony-simulation-experiment-log",
25
+ ]) {
26
+ const r = redactSecrets(slug);
27
+ expect(r.redacted).toBe(0);
28
+ expect(r.text).toBe(slug);
29
+ }
30
+ });
31
+
32
+ it("still scrubs sk- keys that start their own token", () => {
33
+ const quoted = redactSecrets('key="sk-AbCdEfGhIjKlMnOpQrStUvWx1234"');
34
+ expect(quoted.redacted).toBe(1);
35
+ const lineStart = redactSecrets("sk-AbCdEfGhIjKlMnOpQrStUvWx1234 trailing");
36
+ expect(lineStart.redacted).toBe(1);
37
+ });
38
+
20
39
  it("scrubs GitHub tokens", () => {
21
40
  const r = redactSecrets("push with ghp_ABCDEFGHIJKLMNOPQRSTUV1234567890");
22
41
  expect(r.text).toContain("[REDACTED:github_token]");