recallnest 2.6.1 → 3.0.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 (74) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +131 -14
  3. package/README_CN.md +121 -17
  4. package/config.json.example +8 -1
  5. package/docs/api-reference.md +1 -1
  6. package/integrations/README.md +1 -1
  7. package/integrations/agy/README.md +73 -0
  8. package/integrations/agy/setup.sh +75 -0
  9. package/integrations/setup-all.sh +1 -1
  10. package/package.json +5 -4
  11. package/src/__tests__/access-tracker-novelty.test.ts +22 -2
  12. package/src/__tests__/activity-counter.test.ts +38 -38
  13. package/src/__tests__/age-label.test.ts +33 -0
  14. package/src/__tests__/cluster-consolidation.test.ts +313 -22
  15. package/src/__tests__/consolidation-engine.test.ts +90 -0
  16. package/src/__tests__/contracts.test.ts +2 -2
  17. package/src/__tests__/data-checkup.test.ts +31 -3
  18. package/src/__tests__/dream-pipeline.test.ts +41 -1
  19. package/src/__tests__/dream-scope-isolation.test.ts +386 -0
  20. package/src/__tests__/dream-write-batching.test.ts +211 -0
  21. package/src/__tests__/env-config-invariant.test.ts +80 -0
  22. package/src/__tests__/env-config.test.ts +60 -0
  23. package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
  24. package/src/__tests__/maintenance-locks.test.ts +8 -0
  25. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
  26. package/src/__tests__/memory-output.test.ts +9 -0
  27. package/src/__tests__/memory-utility.test.ts +256 -0
  28. package/src/__tests__/openai-contract.test.ts +513 -0
  29. package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
  30. package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
  31. package/src/__tests__/source-heartbeat.test.ts +6 -2
  32. package/src/__tests__/store-patch-metadata.test.ts +30 -0
  33. package/src/__tests__/synthesis-contract.test.ts +252 -0
  34. package/src/__tests__/synthesis-promotion.test.ts +369 -0
  35. package/src/__tests__/trusted-memory.test.ts +28 -0
  36. package/src/__tests__/utility-weight.test.ts +57 -0
  37. package/src/__tests__/verbatim-self-recall.test.ts +279 -0
  38. package/src/__tests__/version-manager.test.ts +35 -0
  39. package/src/access-tracker.ts +10 -1
  40. package/src/activity-counter.ts +69 -11
  41. package/src/age-label.ts +29 -0
  42. package/src/audit-log.ts +35 -0
  43. package/src/auto-gc.ts +97 -30
  44. package/src/cli.ts +224 -10
  45. package/src/consolidation-engine.ts +317 -123
  46. package/src/data-checkup.ts +17 -3
  47. package/src/dream-pipeline.ts +90 -12
  48. package/src/embedder.ts +20 -5
  49. package/src/env-config.ts +53 -0
  50. package/src/forget-engine.ts +8 -2
  51. package/src/gateway-server.ts +272 -0
  52. package/src/ingest.ts +23 -5
  53. package/src/llm-client.ts +86 -25
  54. package/src/mcp-server.ts +2 -1
  55. package/src/mcp-tools-advanced.ts +7 -2
  56. package/src/mcp-tools-core.ts +11 -1
  57. package/src/mcp-tools-governance.ts +45 -3
  58. package/src/memory-evolution.ts +16 -0
  59. package/src/memory-output.ts +12 -8
  60. package/src/memory-promotion.ts +280 -0
  61. package/src/memory-store-port.ts +32 -3
  62. package/src/memory-utility.ts +314 -0
  63. package/src/recall-ledger.ts +76 -0
  64. package/src/retriever.ts +82 -9
  65. package/src/scope-policy.ts +22 -1
  66. package/src/session-output.ts +4 -3
  67. package/src/skill-promotion.ts +152 -1
  68. package/src/store.ts +231 -71
  69. package/src/synthesis-contract.ts +286 -0
  70. package/src/version-manager.ts +83 -0
  71. package/src/workflow-observation-schema.ts +26 -0
  72. package/integrations/gemini-cli/README.md +0 -57
  73. package/integrations/gemini-cli/setup.sh +0 -64
  74. /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Architecture guard for the invariant env-config.ts declares in its own header:
7
+ * it is the single source of truth for every fixed `RECALLNEST_*` env read, and
8
+ * consumers must call its accessors instead of touching `process.env` directly.
9
+ *
10
+ * That invariant was documented but unenforced, and it had already sprung a leak:
11
+ * `cli.ts` read RECALLNEST_DREAM_BUDGET_MS inline and — precisely because it
12
+ * bypassed the accessor layer — was the one read in the repo with no
13
+ * `Number.isFinite` guard, so `""` silently became a 0ms dream budget and any
14
+ * non-numeric value became a NaN deadline that disabled the guard entirely.
15
+ * Bypassing the chokepoint IS how the guard gets lost; hence a test, not a note.
16
+ *
17
+ * Baseline: 0 violations as of 2026-08-18. If this goes red, the fix is normally
18
+ * to add an accessor to env-config.ts — not to widen EXEMPT.
19
+ *
20
+ * Deliberately NOT matched (these are the exemptions env-config.ts's header
21
+ * already names, and each reads differently so the pattern never sees them):
22
+ * - scope-policy.ts → `env.RECALLNEST_*` on an injected env object
23
+ * - store.ts → RECALLNEST_NS is a local constant, not an env var
24
+ * - runtime-config / embedder / llm-client → dynamic `process.env[name]`
25
+ * config-template expansion, which cannot be statically consolidated
26
+ */
27
+
28
+ // Add a file here ONLY with a reason, and only when an accessor genuinely can't
29
+ // work (e.g. the read must happen before env-config's module-init boundary).
30
+ const EXEMPT: ReadonlyArray<{ file: string; why: string }> = [];
31
+
32
+ const DIRECT_READ = /process\.env\.RECALLNEST_[A-Z_0-9]+/g;
33
+
34
+ function collectSourceFiles(dir: string, out: string[] = []): string[] {
35
+ for (const entry of readdirSync(dir)) {
36
+ const full = join(dir, entry);
37
+ if (statSync(full).isDirectory()) {
38
+ if (entry === "__tests__" || entry === "node_modules") continue;
39
+ collectSourceFiles(full, out);
40
+ continue;
41
+ }
42
+ if (!entry.endsWith(".ts") || entry.endsWith(".test.ts") || entry === "env-config.ts") continue;
43
+ out.push(full);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ describe("env-config is the chokepoint for RECALLNEST_* reads", () => {
49
+ it("no src/ module reads process.env.RECALLNEST_* directly", () => {
50
+ const exemptFiles = new Set(EXEMPT.map((e) => e.file));
51
+ const violations: string[] = [];
52
+
53
+ for (const file of collectSourceFiles("src")) {
54
+ if (exemptFiles.has(file)) continue;
55
+ const lines = readFileSync(file, "utf8").split("\n");
56
+ lines.forEach((line, i) => {
57
+ for (const hit of line.match(DIRECT_READ) ?? []) {
58
+ violations.push(`${file}:${i + 1} ${hit}`);
59
+ }
60
+ });
61
+ }
62
+
63
+ expect(
64
+ violations,
65
+ violations.length === 0
66
+ ? ""
67
+ : `Direct RECALLNEST_* env reads bypass env-config.ts's guards:\n ${violations.join("\n ")}\n` +
68
+ `Fix: add an accessor to src/env-config.ts (with its parsing + fallback) and call it here.`,
69
+ ).toEqual([]);
70
+ });
71
+
72
+ it("guard actually detects a violation (so a green run means something)", () => {
73
+ const synthetic = 'const x = Number(process.env.RECALLNEST_SOMETHING_NEW ?? 1);';
74
+ expect(synthetic.match(DIRECT_READ)).toHaveLength(1);
75
+ // And it must not fire on the exempted read styles:
76
+ expect('env.RECALLNEST_SESSION_ID'.match(DIRECT_READ)).toBeNull();
77
+ expect('process.env[envVar]'.match(DIRECT_READ)).toBeNull();
78
+ expect('const RECALLNEST_NS = "recallnest:v1";'.match(DIRECT_READ)).toBeNull();
79
+ });
80
+ });
@@ -21,6 +21,8 @@ const KEYS = [
21
21
  "RECALLNEST_RECALL_MODE",
22
22
  "RECALLNEST_UI_PORT",
23
23
  "RECALLNEST_API_PORT",
24
+ "RECALLNEST_DREAM_BUDGET_MS",
25
+ "RECALLNEST_SYNTHESIS_MODEL",
24
26
  ];
25
27
 
26
28
  const saved: Record<string, string | undefined> = {};
@@ -149,3 +151,61 @@ describe("env-config accessors are lazy (read at call time, never frozen)", () =
149
151
  expect(envConfig.emotionScoring()).toBe(false);
150
152
  });
151
153
  });
154
+
155
+ describe("env-config dreamBudgetMs — the two silent failure modes it exists to stop", () => {
156
+ const FALLBACK = 2 * 60 * 60 * 1000;
157
+
158
+ it("falls back when unset", () => {
159
+ delete process.env.RECALLNEST_DREAM_BUDGET_MS;
160
+ expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(FALLBACK);
161
+ });
162
+
163
+ it('empty string does NOT become 0 (old `?? default` let "" through → Number("") === 0 → every scope skipped)', () => {
164
+ process.env.RECALLNEST_DREAM_BUDGET_MS = "";
165
+ expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(FALLBACK);
166
+ process.env.RECALLNEST_DREAM_BUDGET_MS = " ";
167
+ expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(FALLBACK);
168
+ });
169
+
170
+ it('non-numeric does NOT become NaN (NaN deadline makes `Date.now() > deadline` permanently false → budget guard gone)', () => {
171
+ for (const bad of ["2h", "abc", "30min", "1_000"]) {
172
+ process.env.RECALLNEST_DREAM_BUDGET_MS = bad;
173
+ const ms = envConfig.dreamBudgetMs(FALLBACK);
174
+ expect(Number.isFinite(ms)).toBe(true);
175
+ expect(ms).toBe(FALLBACK);
176
+ // The regression this guards: a NaN budget silently disables the deadline.
177
+ expect(Date.now() > Date.now() + ms).toBe(false);
178
+ }
179
+ });
180
+
181
+ it("rejects zero and negatives (they would expire the deadline instantly)", () => {
182
+ for (const bad of ["0", "-1", "-60000"]) {
183
+ process.env.RECALLNEST_DREAM_BUDGET_MS = bad;
184
+ expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(FALLBACK);
185
+ }
186
+ });
187
+
188
+ it("accepts a valid override", () => {
189
+ process.env.RECALLNEST_DREAM_BUDGET_MS = "900000";
190
+ expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(900_000);
191
+ });
192
+ });
193
+
194
+ describe("env-config synthesisModel — dream 合成的模型旋钮", () => {
195
+ it("未设 → undefined(调用方回落到 config.llm.model,保持老行为)", () => {
196
+ delete process.env.RECALLNEST_SYNTHESIS_MODEL;
197
+ expect(envConfig.synthesisModel()).toBeUndefined();
198
+ });
199
+
200
+ it("空串 / 纯空白也算未设,不会把模型名设成空字符串", () => {
201
+ for (const blank of ["", " ", "\t"]) {
202
+ process.env.RECALLNEST_SYNTHESIS_MODEL = blank;
203
+ expect(envConfig.synthesisModel()).toBeUndefined();
204
+ }
205
+ });
206
+
207
+ it("正常值去空白后返回", () => {
208
+ process.env.RECALLNEST_SYNTHESIS_MODEL = " qwen-plus ";
209
+ expect(envConfig.synthesisModel()).toBe("qwen-plus");
210
+ });
211
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 每个会话来源的 scope 前缀必须在 memory-boundaries 的清单里登记。
3
+ *
4
+ * 这条约束是承重的,不是整洁问题:TRANSCRIPT_SCOPE_PREFIXES 决定了会话碎片
5
+ * 会不会被降权到 evidence 层。漏登记一个前缀,那个端的 shell 输出就会被当成
6
+ * 「用户说过的话」提炼成 durable 结论——kimi 与 antigravity 就这么漏过
7
+ * (2026-07 进栈,直到 2026-08-01 才发现库里一直被当结论对待)。
8
+ *
9
+ * 所以加新 ingest 源时,这个测试会在你只改了 config.json / cli.ts 却忘了
10
+ * 登记前缀的时候变红。
11
+ */
12
+ import { describe, expect, it } from "bun:test";
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { isTranscriptIngestSource, isTranscriptScope } from "../memory-boundaries.js";
16
+
17
+ const config = JSON.parse(
18
+ readFileSync(join(import.meta.dir, "..", "..", "config.json"), "utf8"),
19
+ ) as { sources: Record<string, { path: string; glob: string; description?: string }> };
20
+
21
+ // config 源名 → 它写进库的 scope 前缀。
22
+ // desktop 沿用 cc 前缀是历史行为(Claude Desktop 导出的就是 CC 格式对话)。
23
+ const SOURCE_SCOPE_PREFIX: Record<string, string | null> = {
24
+ cc: "cc",
25
+ codex: "codex",
26
+ gemini: "gemini",
27
+ kimi: "kimi",
28
+ desktop: "cc",
29
+ minis: "minis",
30
+ memory: null, // markdown 记忆文件,不是会话记录,不走 transcript 降权
31
+ };
32
+
33
+ describe("ingest 源的 scope 前缀登记", () => {
34
+ it("config.json 里每个源都在本测试的映射表里有交代", () => {
35
+ for (const name of Object.keys(config.sources)) {
36
+ expect(
37
+ Object.prototype.hasOwnProperty.call(SOURCE_SCOPE_PREFIX, name),
38
+ ).toBe(true);
39
+ }
40
+ });
41
+
42
+ it("每个会话类来源的 scope 都被判定为 transcript 证据(会被降权)", () => {
43
+ for (const [name, prefix] of Object.entries(SOURCE_SCOPE_PREFIX)) {
44
+ if (prefix === null) continue;
45
+ // 断言里带上源名,红的时候直接看得出是哪个源漏登记
46
+ expect(`${name}:${isTranscriptScope(`${prefix}:abcd1234`)}`).toBe(`${name}:true`);
47
+ }
48
+ });
49
+
50
+ it("minis 源已接线:config 有条目且前缀被降权识别", () => {
51
+ expect(config.sources.minis).toBeDefined();
52
+ expect(config.sources.minis.glob).toBe("*.jsonl");
53
+ expect(isTranscriptScope("minis:9f3e7a21")).toBe(true);
54
+ });
55
+
56
+ it("同一个前缀也必须在 source 清单里登记(两道闸,漏一道就静默失真)", () => {
57
+ // isTranscriptScope 看 scope、isTranscriptSource 看条目的 source 字段。
58
+ // 只登记前者时降权仍然生效,错误不会报出来,只会让「按来源过滤」查不准。
59
+ for (const [name, prefix] of Object.entries(SOURCE_SCOPE_PREFIX)) {
60
+ if (prefix === null) continue;
61
+ expect(`${name}:${isTranscriptIngestSource(prefix)}`).toBe(`${name}:true`);
62
+ }
63
+ });
64
+
65
+ it("一条反向断言:没登记过的前缀不会被误判成 transcript", () => {
66
+ expect(isTranscriptScope("project:recallnest")).toBe(false);
67
+ expect(isTranscriptScope("memory:pivot")).toBe(false);
68
+ });
69
+ });
@@ -48,9 +48,11 @@ describe("runDream cross-process lock", () => {
48
48
  bodyRan = true;
49
49
  return { totalCount: 100, scopeCounts: {}, categoryCounts: {} };
50
50
  },
51
+ repairSingletonVersionGroups: async () => 0,
51
52
  list: async () => [],
52
53
  listPage: async () => [],
53
54
  update: async () => null,
55
+ patchMetadataBatch: async (patches: Array<{ id: string; patchFn: unknown }>) => patches.length,
54
56
  patchMetadata: async () => null,
55
57
  };
56
58
 
@@ -83,9 +85,11 @@ describe("runDream cross-process lock", () => {
83
85
  }));
84
86
  const store = {
85
87
  stats: async () => ({ totalCount: 100, scopeCounts: {}, categoryCounts: {} }),
88
+ repairSingletonVersionGroups: async () => 0,
86
89
  list: async () => entries,
87
90
  listPage: async () => [],
88
91
  update: async () => null,
92
+ patchMetadataBatch: async (patches: Array<{ id: string; patchFn: unknown }>) => patches.length,
89
93
  patchMetadata: async () => null,
90
94
  getById: async () => null,
91
95
  vectorSearch: async () => [],
@@ -123,6 +127,7 @@ describe("runDream cross-process lock", () => {
123
127
 
124
128
  const store = {
125
129
  stats: async () => ({ totalCount: 5, scopeCounts: {}, categoryCounts: {} }),
130
+ repairSingletonVersionGroups: async () => 0,
126
131
  // ⚠️ 2026-08-12 标注:这里 list 返回空数组,走的是 **completed_early 早退路径**,
127
132
  // 而早退路径 (dream-pipeline.ts:226) 的 resetWriteCount(scope, statsCfg) 一直是对的。
128
133
  // 本条测试因此从未覆盖「完整跑完」那条路径 —— 而 bug 恰恰在那条路径上(:301 漏传 scope)。
@@ -132,6 +137,7 @@ describe("runDream cross-process lock", () => {
132
137
  list: async () => [], // no active entries → completed_early path (still resets)
133
138
  listPage: async () => [],
134
139
  update: async () => null,
140
+ patchMetadataBatch: async (patches: Array<{ id: string; patchFn: unknown }>) => patches.length,
135
141
  patchMetadata: async () => null,
136
142
  getById: async () => null,
137
143
  vectorSearch: async () => [],
@@ -156,8 +162,10 @@ describe("maybeRunGc cross-process lock + throttle", () => {
156
162
  function makeStore(totalCount: number) {
157
163
  return {
158
164
  stats: async () => ({ totalCount, scopeCounts: {}, categoryCounts: {} }),
165
+ repairSingletonVersionGroups: async () => 0,
159
166
  listPage: async () => [] as unknown[],
160
167
  update: async () => null,
168
+ patchMetadataBatch: async (patches: Array<{ id: string; patchFn: unknown }>) => patches.length,
161
169
  list: async () => [],
162
170
  } as any;
163
171
  }
@@ -59,6 +59,7 @@ describe("B-2: Archive Strategy", () => {
59
59
  return {
60
60
  store: {
61
61
  stats: async () => ({ totalCount: data.size, scopeCounts: {}, categoryCounts: {} }),
62
+ repairSingletonVersionGroups: async () => 0,
62
63
  list: async () => Array.from(data.values()),
63
64
  listPage: async (opts: { limit?: number; offset?: number } = {}) => {
64
65
  const { limit = 1000, offset = 0 } = opts;
@@ -71,6 +72,22 @@ describe("B-2: Archive Strategy", () => {
71
72
  entry.metadata = patch.metadata;
72
73
  return entry;
73
74
  },
75
+ // 2026-08-14 auto-gc 批量归档通道:以 data 最新行起底应用 patchFn(生产语义)
76
+ patchMetadataBatch: async (
77
+ patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: unknown) => Record<string, unknown> }>,
78
+ ) => {
79
+ let written = 0;
80
+ for (const { id, patchFn } of patches) {
81
+ const entry = data.get(id);
82
+ if (!entry) continue;
83
+ let meta: Record<string, unknown>;
84
+ try { meta = JSON.parse(entry.metadata || "{}") as Record<string, unknown>; } catch { meta = {}; }
85
+ entry.metadata = JSON.stringify(patchFn(meta, entry));
86
+ updates.push({ id, metadata: entry.metadata });
87
+ written++;
88
+ }
89
+ return written;
90
+ },
74
91
  },
75
92
  updates,
76
93
  };
@@ -25,6 +25,15 @@ function buildResult(id: string, metadata: Record<string, unknown>): RetrievalRe
25
25
  }
26
26
 
27
27
  describe("memory output", () => {
28
+ it("renders a whole-day age next to every date so nobody has to subtract", () => {
29
+ const result = buildResult("abcd1234-0000-0000-0000-000000000009", { source: "agent" });
30
+ const search = formatSearchResults([result], { query: "concise", profile: "default" } as any);
31
+ expect(search).toContain("Date Age Retrieval Path");
32
+ expect(search).toMatch(/2026-03-16 \d+d\s+vector/);
33
+ const explain = formatExplainResults([result], { query: "concise", profile: "default" } as any);
34
+ expect(explain).toMatch(/2026-03-16 \(\d+d\)/);
35
+ });
36
+
28
37
  it("includes provenance in search results", () => {
29
38
  const output = formatSearchResults([
30
39
  buildResult("abcd1234-0000-0000-0000-000000000001", {
@@ -0,0 +1,256 @@
1
+ import { describe, expect, it, beforeEach } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ applyUtilityToMeta,
8
+ computeMemoryUtility,
9
+ formatMemoryUtility,
10
+ JOIN_WEIGHT,
11
+ linkMemoryToObservations,
12
+ OUTCOME_REWARD,
13
+ readUtility,
14
+ summarizeMemoryOutcomes,
15
+ UTILITY_FIELD,
16
+ } from "../memory-utility.js";
17
+ import { buildWorkflowObservationRecord } from "../workflow-observation-engine.js";
18
+ import { WorkflowObservationStore } from "../workflow-observation-store.js";
19
+ import {
20
+ PROCESS_READER_ID,
21
+ RECALLED_IDS_CAP,
22
+ recallLedgerSize,
23
+ recentRecallHits,
24
+ recordRecallHits,
25
+ resetRecallLedger,
26
+ } from "../recall-ledger.js";
27
+ import { AccessTracker } from "../access-tracker.js";
28
+ import { isDecayExempt, resolveTier } from "../decay-engine.js";
29
+
30
+ const MEM_A = "aaaaaaaa-0000-4000-8000-000000000001";
31
+ const MEM_B = "bbbbbbbb-0000-4000-8000-000000000002";
32
+
33
+ function obs(overrides: Record<string, unknown>) {
34
+ return buildWorkflowObservationRecord({
35
+ workflowId: "resume_context",
36
+ summary: "test observation",
37
+ ...overrides,
38
+ });
39
+ }
40
+
41
+ describe("recall ledger (P0 join key)", () => {
42
+ beforeEach(() => resetRecallLedger());
43
+
44
+ it("records hits and returns them newest-first within the window", () => {
45
+ const t0 = 1_000_000;
46
+ recordRecallHits([MEM_A], t0);
47
+ recordRecallHits([MEM_B], t0 + 1000);
48
+ expect(recentRecallHits(60_000, 10, t0 + 2000)).toEqual([MEM_B, MEM_A]);
49
+ });
50
+
51
+ it("drops hits older than the window", () => {
52
+ const t0 = 1_000_000;
53
+ recordRecallHits([MEM_A], t0);
54
+ recordRecallHits([MEM_B], t0 + 60 * 60_000);
55
+ expect(recentRecallHits(30 * 60_000, 10, t0 + 60 * 60_000)).toEqual([MEM_B]);
56
+ });
57
+
58
+ it("caps the returned list", () => {
59
+ const ids = Array.from({ length: RECALLED_IDS_CAP + 20 }, (_, i) => `id-${i}`);
60
+ recordRecallHits(ids, 5_000);
61
+ expect(recentRecallHits(60_000, RECALLED_IDS_CAP, 5_000).length).toBe(RECALLED_IDS_CAP);
62
+ });
63
+
64
+ it("gives every AccessTracker the same process-level reader id", () => {
65
+ // 回归防线:readerId 曾是 per-instance 随机的,而 components 按 profile 缓存 —— 同一个
66
+ // 会话开两个 profile 就伪造出两个 reader,join 直接失效。
67
+ const fakeStore = {} as never;
68
+ expect(new AccessTracker(fakeStore).readerId).toBe(PROCESS_READER_ID);
69
+ expect(new AccessTracker(fakeStore).readerId).toBe(new AccessTracker(fakeStore).readerId);
70
+ expect(new AccessTracker(fakeStore, undefined, "r-explicit").readerId).toBe("r-explicit");
71
+ });
72
+
73
+ it("records into the ledger before the novelty/cooldown gate", () => {
74
+ // 高相似度 + 严格 novelty 闸:强化会被挡下,但 agent 确实看到了这条 —— 账本必须记。
75
+ const fakeStore = {} as never;
76
+ const tracker = new AccessTracker(fakeStore, {
77
+ flushIntervalMs: 5000,
78
+ reinforcementFactor: 0.5,
79
+ maxMultiplier: 3,
80
+ accessFreshnessHalfLifeDays: 30,
81
+ noveltyThreshold: 0.9,
82
+ cooldownMs: 0,
83
+ });
84
+ tracker.recordAccess([MEM_A], [0.99]);
85
+ expect(tracker.pendingCount).toBe(0);
86
+ expect(recentRecallHits()).toContain(MEM_A);
87
+ tracker.destroy();
88
+ });
89
+ });
90
+
91
+ describe("memory ↔ observation join", () => {
92
+ it("joins exactly when the observation names the memory", () => {
93
+ const links = linkMemoryToObservations(MEM_A, "{}", [
94
+ obs({ recalledIds: [MEM_A, MEM_B], outcome: "success" }),
95
+ obs({ recalledIds: [MEM_B], outcome: "failure" }),
96
+ ]);
97
+ expect(links.length).toBe(1);
98
+ expect(links[0].join).toBe("exact");
99
+ });
100
+
101
+ it("falls back to session join through readerId ∩ metadata.readerIds", () => {
102
+ const meta = JSON.stringify({ readerIds: ["r-1234abcd"] });
103
+ const links = linkMemoryToObservations(MEM_A, meta, [
104
+ obs({ readerId: "r-1234abcd", outcome: "corrected" }),
105
+ obs({ readerId: "r-otherxx", outcome: "failure" }),
106
+ ]);
107
+ expect(links.length).toBe(1);
108
+ expect(links[0].join).toBe("session");
109
+ expect(links[0].outcome).toBe("corrected");
110
+ });
111
+
112
+ it("prefers exact over session for the same observation", () => {
113
+ const meta = JSON.stringify({ readerIds: ["r-1234abcd"] });
114
+ const links = linkMemoryToObservations(MEM_A, meta, [
115
+ obs({ readerId: "r-1234abcd", recalledIds: [MEM_A], outcome: "success" }),
116
+ ]);
117
+ expect(links.length).toBe(1);
118
+ expect(links[0].join).toBe("exact");
119
+ });
120
+
121
+ it("returns nothing when neither key is present (the pre-P0 state)", () => {
122
+ expect(linkMemoryToObservations(MEM_A, "{}", [obs({ outcome: "success" })])).toEqual([]);
123
+ });
124
+ });
125
+
126
+ describe("utility computation", () => {
127
+ const now = Date.parse("2026-08-23T00:00:00.000Z");
128
+ const at = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
129
+
130
+ it("answers 「这条 memory 参与过的任务成功率是多少」", () => {
131
+ const summary = summarizeMemoryOutcomes(MEM_A, [
132
+ { observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(1), join: "exact", summary: "" },
133
+ { observationId: "o2", workflowId: "w", outcome: "success", recordedAt: at(2), join: "exact", summary: "" },
134
+ { observationId: "o3", workflowId: "w", outcome: "failure", recordedAt: at(3), join: "exact", summary: "" },
135
+ ], now);
136
+ expect(summary.total).toBe(3);
137
+ expect(summary.successRate).toBeCloseTo(2 / 3, 5);
138
+ expect(summary.exactSuccessRate).toBeCloseTo(2 / 3, 5);
139
+ });
140
+
141
+ it("goes negative when the tasks mostly failed — importance could never hold this", () => {
142
+ const summary = summarizeMemoryOutcomes(MEM_A, [
143
+ { observationId: "o1", workflowId: "w", outcome: "failure", recordedAt: at(0), join: "exact", summary: "" },
144
+ { observationId: "o2", workflowId: "w", outcome: "failure", recordedAt: at(1), join: "exact", summary: "" },
145
+ { observationId: "o3", workflowId: "w", outcome: "missed", recordedAt: at(2), join: "exact", summary: "" },
146
+ ], now);
147
+ expect(summary.utility).not.toBeNull();
148
+ expect(summary.utility!).toBeLessThan(0);
149
+ });
150
+
151
+ it("weights session joins below exact ones", () => {
152
+ const exact = summarizeMemoryOutcomes(MEM_A, [
153
+ { observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(0), join: "exact", summary: "" },
154
+ ], now);
155
+ const session = summarizeMemoryOutcomes(MEM_A, [
156
+ { observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(0), join: "session", summary: "" },
157
+ ], now);
158
+ expect(exact.effectiveSamples).toBeCloseTo(JOIN_WEIGHT.exact, 5);
159
+ expect(session.effectiveSamples).toBeCloseTo(JOIN_WEIGHT.session, 5);
160
+ // 同一个 outcome,加权平均的值一样,可信度不同 —— 差别落在 effectiveSamples 上。
161
+ expect(exact.utility).toBeCloseTo(OUTCOME_REWARD.success, 5);
162
+ });
163
+
164
+ it("decays old outcomes by the 30-day half-life", () => {
165
+ const fresh = summarizeMemoryOutcomes(MEM_A, [
166
+ { observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(0), join: "exact", summary: "" },
167
+ ], now);
168
+ const old = summarizeMemoryOutcomes(MEM_A, [
169
+ { observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(30), join: "exact", summary: "" },
170
+ ], now);
171
+ expect(old.effectiveSamples).toBeCloseTo(fresh.effectiveSamples * 0.5, 3);
172
+ });
173
+
174
+ it("ignores session-join noise once exact samples exist", () => {
175
+ // 2026-08-23 落地当天的实测:同一会话跑多个任务时,readerId join 会把无关任务的
176
+ // 成败摊到每条被读过的记忆上——那次 5 条记忆的会话级成功率全是 44%(常数,零信息),
177
+ // 精确成功率却是 100%/33%/0%。所以有精确样本时不掺会话级。
178
+ const links = [
179
+ { observationId: "e1", workflowId: "w", outcome: "success" as const, recordedAt: at(0), join: "exact" as const, summary: "" },
180
+ { observationId: "e2", workflowId: "w", outcome: "success" as const, recordedAt: at(0), join: "exact" as const, summary: "" },
181
+ { observationId: "s1", workflowId: "w", outcome: "failure" as const, recordedAt: at(0), join: "session" as const, summary: "" },
182
+ { observationId: "s2", workflowId: "w", outcome: "failure" as const, recordedAt: at(0), join: "session" as const, summary: "" },
183
+ ];
184
+ const preferred = summarizeMemoryOutcomes(MEM_A, links, now);
185
+ expect(preferred.utilityBasis).toBe("exact");
186
+ expect(preferred.utility).toBeCloseTo(OUTCOME_REWARD.success, 5);
187
+
188
+ const mixed = summarizeMemoryOutcomes(MEM_A, links, now, { preferExact: false });
189
+ expect(mixed.utility!).toBeLessThan(preferred.utility!);
190
+ });
191
+
192
+ it("falls back to session join when nothing named this memory", () => {
193
+ const links = Array.from({ length: 4 }, (_, i) => ({
194
+ observationId: `s${i}`, workflowId: "w", outcome: "success" as const,
195
+ recordedAt: at(0), join: "session" as const, summary: "",
196
+ }));
197
+ const summary = summarizeMemoryOutcomes(MEM_A, links, now);
198
+ expect(summary.utilityBasis).toBe("session");
199
+ expect(summary.utility).toBeCloseTo(OUTCOME_REWARD.success, 5);
200
+ });
201
+
202
+ it("returns null (not 0) when there is no evidence", () => {
203
+ const summary = summarizeMemoryOutcomes(MEM_A, [], now);
204
+ expect(summary.utility).toBeNull();
205
+ expect(summary.successRate).toBeNull();
206
+ expect(summary.utilityBasis).toBe("none");
207
+ });
208
+ });
209
+
210
+ describe("utility metadata write-back", () => {
211
+ it("writes utility without touching importance-derived behaviour", () => {
212
+ const summary = summarizeMemoryOutcomes(MEM_A, [
213
+ { observationId: "o1", workflowId: "w", outcome: "failure", recordedAt: new Date().toISOString(), join: "exact", summary: "" },
214
+ ]);
215
+ const before = { tier: "peripheral", accessCount: 2 };
216
+ const after = applyUtilityToMeta(before, summary);
217
+ expect(after[UTILITY_FIELD]).toBeLessThan(0);
218
+ // 硬约束:utility 不进 tier、不进 decay 豁免。
219
+ expect(resolveTier(JSON.stringify(after), 0.7)).toBe(resolveTier(JSON.stringify(before), 0.7));
220
+ expect(isDecayExempt(JSON.stringify(after), 0.7, "cases"))
221
+ .toBe(isDecayExempt(JSON.stringify(before), 0.7, "cases"));
222
+ expect((after as Record<string, unknown>).importance).toBeUndefined();
223
+ });
224
+
225
+ it("clears utility instead of writing 0 when the evidence is gone", () => {
226
+ const meta = { utility: -0.5, utilitySamples: 1, utilityUpdatedAt: 1 };
227
+ const cleared = applyUtilityToMeta(meta, summarizeMemoryOutcomes(MEM_A, []));
228
+ expect(UTILITY_FIELD in cleared).toBe(false);
229
+ expect(readUtility(JSON.stringify(cleared))).toBeNull();
230
+ });
231
+ });
232
+
233
+ describe("end-to-end through the observation store", () => {
234
+ it("round-trips join keys and answers the success-rate query", async () => {
235
+ const dir = mkdtempSync(join(tmpdir(), "recallnest-utility-obs-"));
236
+ const store = new WorkflowObservationStore(dir);
237
+ await store.save(obs({ recalledIds: [MEM_A], outcome: "success", readerId: "r-sess0001" }));
238
+ await store.save(obs({ recalledIds: [MEM_A], outcome: "failure", readerId: "r-sess0001", workflowId: "checkpoint_session" }));
239
+ await store.save(obs({ recalledIds: [MEM_B], outcome: "success", readerId: "r-sess0001" }));
240
+
241
+ const records = await store.listRecent({ limit: 100 });
242
+ const summary = computeMemoryUtility({ id: MEM_A, metadata: "{}" }, records);
243
+ expect(summary.total).toBe(2);
244
+ expect(summary.successRate).toBeCloseTo(0.5, 5);
245
+ expect(formatMemoryUtility(summary, { showLinks: 2 })).toContain("成功率");
246
+ });
247
+
248
+ it("still parses observations written before the join-key fields existed", async () => {
249
+ const dir = mkdtempSync(join(tmpdir(), "recallnest-utility-legacy-"));
250
+ const store = new WorkflowObservationStore(dir);
251
+ const legacy = obs({ outcome: "success" });
252
+ expect((legacy as Record<string, unknown>).recalledIds).toBeUndefined();
253
+ await store.save(legacy);
254
+ expect((await store.listRecent({ limit: 10 })).length).toBe(1);
255
+ });
256
+ });