recallnest 2.3.0 → 2.5.4

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 (221) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +17 -31
  3. package/README_CN.md +12 -22
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +22 -1
  15. package/src/__tests__/access-tracker-novelty.test.ts +238 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/auto-consolidation.test.ts +168 -0
  21. package/src/__tests__/auto-recall.test.ts +131 -0
  22. package/src/__tests__/babel-integration.test.ts +144 -0
  23. package/src/__tests__/batch-dedup.test.ts +65 -0
  24. package/src/__tests__/canary-eval.test.ts +114 -0
  25. package/src/__tests__/capture-engine.test.ts +1238 -0
  26. package/src/__tests__/capture-heuristic.test.ts +171 -0
  27. package/src/__tests__/cascade-forget.test.ts +210 -0
  28. package/src/__tests__/category-min-scores.test.ts +33 -0
  29. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  30. package/src/__tests__/circuit-breaker.test.ts +99 -0
  31. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  32. package/src/__tests__/confidence-metadata.test.ts +125 -0
  33. package/src/__tests__/confidence-tracker.test.ts +261 -0
  34. package/src/__tests__/conflict-advisor.test.ts +206 -0
  35. package/src/__tests__/conflict-escalation.test.ts +149 -0
  36. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  37. package/src/__tests__/conflict-output.test.ts +85 -0
  38. package/src/__tests__/conflict-store.test.ts +374 -0
  39. package/src/__tests__/connector-v1.test.ts +181 -0
  40. package/src/__tests__/consolidation-engine.test.ts +333 -0
  41. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  42. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  43. package/src/__tests__/context-composer-pins.test.ts +64 -0
  44. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  45. package/src/__tests__/context-reconstructor.test.ts +65 -0
  46. package/src/__tests__/context-renderer.test.ts +91 -0
  47. package/src/__tests__/continuity-eval.test.ts +275 -0
  48. package/src/__tests__/continuity-schema.test.ts +97 -0
  49. package/src/__tests__/contracts.test.ts +216 -0
  50. package/src/__tests__/conversation-importer.test.ts +692 -0
  51. package/src/__tests__/core-recall-eval.test.ts +235 -0
  52. package/src/__tests__/core-summary.test.ts +109 -0
  53. package/src/__tests__/data-checkup.test.ts +276 -0
  54. package/src/__tests__/decay-exemption.test.ts +84 -0
  55. package/src/__tests__/dedup-actions.test.ts +58 -0
  56. package/src/__tests__/dedup-guard.test.ts +39 -0
  57. package/src/__tests__/deterministic-id.test.ts +39 -0
  58. package/src/__tests__/distill-lock.test.ts +305 -0
  59. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  60. package/src/__tests__/doctor.test.ts +319 -0
  61. package/src/__tests__/dream-pipeline.test.ts +321 -0
  62. package/src/__tests__/drill-down.test.ts +108 -0
  63. package/src/__tests__/embedder.test.ts +122 -0
  64. package/src/__tests__/emotion-decay.test.ts +46 -0
  65. package/src/__tests__/emotion-detector.test.ts +56 -0
  66. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  67. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  68. package/src/__tests__/entity-resolver.test.ts +58 -0
  69. package/src/__tests__/env-config.test.ts +151 -0
  70. package/src/__tests__/error-signature-boost.test.ts +87 -0
  71. package/src/__tests__/error-signature.test.ts +95 -0
  72. package/src/__tests__/error-taxonomy.test.ts +129 -0
  73. package/src/__tests__/ethics-layer.test.ts +476 -0
  74. package/src/__tests__/event-segmenter.test.ts +153 -0
  75. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  76. package/src/__tests__/frequency-tracker.test.ts +113 -0
  77. package/src/__tests__/graph-export.test.ts +489 -0
  78. package/src/__tests__/hotness-blend.test.ts +76 -0
  79. package/src/__tests__/ingest-chunking.test.ts +44 -0
  80. package/src/__tests__/ingest.test.ts +360 -0
  81. package/src/__tests__/interference-detector.test.ts +203 -0
  82. package/src/__tests__/kg-extractor.test.ts +192 -0
  83. package/src/__tests__/kg-store.test.ts +300 -0
  84. package/src/__tests__/llm-consolidation.test.ts +226 -0
  85. package/src/__tests__/maintenance-locks.test.ts +174 -0
  86. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  87. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  88. package/src/__tests__/memory-boundaries.test.ts +250 -0
  89. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  90. package/src/__tests__/memory-evolution.test.ts +271 -0
  91. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  92. package/src/__tests__/memory-lint.test.ts +421 -0
  93. package/src/__tests__/memory-output.test.ts +215 -0
  94. package/src/__tests__/memory-promotion.test.ts +315 -0
  95. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  96. package/src/__tests__/multi-vector.test.ts +242 -0
  97. package/src/__tests__/narrative-architecture.test.ts +456 -0
  98. package/src/__tests__/noise-filter.test.ts +205 -0
  99. package/src/__tests__/obsidian-connector.test.ts +363 -0
  100. package/src/__tests__/omitted-reporting.test.ts +98 -0
  101. package/src/__tests__/pending-review.test.ts +77 -0
  102. package/src/__tests__/pii-redact.test.ts +314 -0
  103. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  104. package/src/__tests__/ppr-traversal.test.ts +256 -0
  105. package/src/__tests__/prediction-engine.test.ts +349 -0
  106. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  107. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  108. package/src/__tests__/preference-matcher.test.ts +230 -0
  109. package/src/__tests__/preference-slots.test.ts +205 -0
  110. package/src/__tests__/prospective-memory.test.ts +283 -0
  111. package/src/__tests__/query-entity-detector.test.ts +171 -0
  112. package/src/__tests__/query-expander-alias.test.ts +146 -0
  113. package/src/__tests__/query-expander.test.ts +34 -0
  114. package/src/__tests__/recall-governor.test.ts +227 -0
  115. package/src/__tests__/recall-mode.test.ts +64 -0
  116. package/src/__tests__/result-synthesizer.test.ts +198 -0
  117. package/src/__tests__/resume-context.test.ts +4898 -0
  118. package/src/__tests__/retrieval-trace.test.ts +213 -0
  119. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  120. package/src/__tests__/retroactive-boost.test.ts +223 -0
  121. package/src/__tests__/salience-scoring.test.ts +106 -0
  122. package/src/__tests__/scope-inventory.test.ts +200 -0
  123. package/src/__tests__/scope-policy.test.ts +69 -0
  124. package/src/__tests__/scope-suggester.test.ts +80 -0
  125. package/src/__tests__/session-checkpoint.test.ts +291 -0
  126. package/src/__tests__/session-distiller.test.ts +640 -0
  127. package/src/__tests__/session-gc.test.ts +215 -0
  128. package/src/__tests__/skill-engine.test.ts +552 -0
  129. package/src/__tests__/skill-promotion.test.ts +601 -0
  130. package/src/__tests__/skill-verifier.test.ts +122 -0
  131. package/src/__tests__/source-heartbeat.test.ts +250 -0
  132. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  133. package/src/__tests__/store-scope-audit.test.ts +81 -0
  134. package/src/__tests__/store-upsert.test.ts +184 -0
  135. package/src/__tests__/supersede-chain.test.ts +154 -0
  136. package/src/__tests__/temporal-parser.test.ts +241 -0
  137. package/src/__tests__/temporal-validity.test.ts +160 -0
  138. package/src/__tests__/term-registry.test.ts +121 -0
  139. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  140. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  141. package/src/__tests__/topic-tag.test.ts +113 -0
  142. package/src/__tests__/trusted-memory.test.ts +839 -0
  143. package/src/__tests__/usage-tracker.test.ts +287 -0
  144. package/src/__tests__/version-manager.test.ts +213 -0
  145. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  146. package/src/__tests__/workflow-observation.test.ts +433 -0
  147. package/src/__tests__/write-verifier.test.ts +122 -0
  148. package/src/access-tracker.ts +38 -32
  149. package/src/activity-counter.ts +46 -18
  150. package/src/aliases.ts +133 -0
  151. package/src/api-server.ts +27 -7
  152. package/src/audit-log.ts +4 -2
  153. package/src/auto-consolidation.ts +8 -3
  154. package/src/auto-gc.ts +85 -21
  155. package/src/capture-engine.ts +54 -21
  156. package/src/cli.ts +169 -37
  157. package/src/confidence-tracker.ts +36 -18
  158. package/src/consolidation-engine.ts +109 -11
  159. package/src/context-composer.ts +8 -1
  160. package/src/data-checkup.ts +95 -6
  161. package/src/distill-lock.ts +194 -15
  162. package/src/doctor.ts +9 -2
  163. package/src/dream-pipeline.ts +118 -34
  164. package/src/env-config.ts +77 -0
  165. package/src/error-signature.ts +153 -0
  166. package/src/error-taxonomy.ts +115 -0
  167. package/src/eval.ts +251 -6
  168. package/src/idempotency.ts +8 -0
  169. package/src/ingest.ts +31 -10
  170. package/src/kg-extractor.ts +6 -2
  171. package/src/kg-store.ts +236 -44
  172. package/src/llm-consolidation.ts +2 -1
  173. package/src/mcp-server.ts +85 -1799
  174. package/src/mcp-tool-deps.ts +48 -0
  175. package/src/mcp-tools-advanced.ts +941 -0
  176. package/src/mcp-tools-core.ts +633 -0
  177. package/src/mcp-tools-governance.ts +475 -0
  178. package/src/memory-boundaries.ts +3 -1
  179. package/src/memory-evolution.ts +73 -1
  180. package/src/memory-lint.ts +183 -10
  181. package/src/memory-output.ts +114 -3
  182. package/src/memory-promotion.ts +246 -0
  183. package/src/memory-schema.ts +25 -2
  184. package/src/memory-store-port.ts +43 -0
  185. package/src/multi-vector.ts +2 -1
  186. package/src/narrative-schema.ts +2 -1
  187. package/src/noise-filter.ts +57 -6
  188. package/src/pii-detector.ts +157 -5
  189. package/src/query-expander.ts +90 -9
  190. package/src/result-synthesizer.ts +2 -1
  191. package/src/retention-policy.ts +2 -1
  192. package/src/retriever.ts +259 -139
  193. package/src/runtime-config.ts +8 -1
  194. package/src/scope-suggester.ts +113 -0
  195. package/src/session-distiller.ts +23 -2
  196. package/src/session-engine.ts +2 -1
  197. package/src/session-schema.ts +1 -0
  198. package/src/session-store.ts +5 -0
  199. package/src/skill-engine.ts +78 -0
  200. package/src/skill-promotion.ts +247 -47
  201. package/src/skill-schema.ts +16 -3
  202. package/src/skill-verifier.ts +152 -0
  203. package/src/store.ts +267 -38
  204. package/src/tool-output-compressor.ts +3 -1
  205. package/src/ui-server.ts +2 -1
  206. package/src/usage-tracker.ts +242 -0
  207. package/src/version-manager.ts +3 -2
  208. package/src/workflow-observation-engine.ts +2 -1
  209. package/src/workflow-observation-schema.ts +2 -1
  210. package/src/workflow-observation-store.ts +6 -2
  211. package/.env +0 -4
  212. package/CLAUDE.md +0 -46
  213. package/config.json +0 -55
  214. package/daily-recall.sh +0 -110
  215. package/logs/daily-recall.error.log +0 -0
  216. package/logs/daily-recall.log +0 -60
  217. package/logs/ingest-2026-04-11.log +0 -110
  218. package/logs/ingest-2026-04-12.log +0 -90
  219. package/logs/ingest-2026-04-13.log +0 -87
  220. package/logs/launchagent-stderr.log +0 -0
  221. package/logs/launchagent-stdout.log +0 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tests for Tier 3.7: LLM-Driven Consolidation
3
+ *
4
+ * Validates:
5
+ * 1. evaluateCluster returns merge decisions from LLM
6
+ * 2. evaluateCluster falls back conservatively on LLM failure
7
+ * 3. executeMergeDecisions creates version groups for merge groups
8
+ * 4. executeMergeDecisions handles empty merge groups
9
+ * 5. isLLMConsolidationEnabled respects env var
10
+ */
11
+ import { describe, expect, it, afterEach } from "bun:test";
12
+ import {
13
+ evaluateCluster,
14
+ executeMergeDecisions,
15
+ isLLMConsolidationEnabled,
16
+ } from "../llm-consolidation.js";
17
+ import type { MemoryEntry } from "../store.js";
18
+ import type { LLMClient } from "../llm-client.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function makeEntry(id: string, text: string, importance = 0.7): MemoryEntry {
25
+ return {
26
+ id,
27
+ text,
28
+ vector: [1, 0, 0],
29
+ category: "preferences",
30
+ scope: "project:test",
31
+ importance,
32
+ timestamp: Date.now(),
33
+ metadata: JSON.stringify({ confidence: 0.7, accessCount: 0 }),
34
+ };
35
+ }
36
+
37
+ function createMockStore(entries: MemoryEntry[]) {
38
+ const data = new Map(entries.map(e => [e.id, { ...e }]));
39
+ const updates: Array<{ id: string; metadata: string }> = [];
40
+
41
+ return {
42
+ data,
43
+ updates,
44
+ async getById(id: string) { return data.get(id) ?? null; },
45
+ async update(id: string, upd: { metadata?: string }, _scope?: string[]) {
46
+ const entry = data.get(id);
47
+ if (!entry) return null;
48
+ if (upd.metadata) {
49
+ entry.metadata = upd.metadata;
50
+ updates.push({ id, metadata: upd.metadata });
51
+ }
52
+ return entry;
53
+ },
54
+ };
55
+ }
56
+
57
+ function createMockLLM(response: string): LLMClient {
58
+ return {
59
+ async synthesizeFragments() {
60
+ return response;
61
+ },
62
+ } as any;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // evaluateCluster
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe("evaluateCluster", () => {
70
+ it("returns merge groups from LLM response", async () => {
71
+ const entries = [
72
+ makeEntry("a", "User prefers dark mode"),
73
+ makeEntry("b", "User likes dark theme"),
74
+ makeEntry("c", "User uses vim keybindings"),
75
+ ];
76
+
77
+ const llm = createMockLLM(JSON.stringify({
78
+ mergeGroups: [[0, 1]],
79
+ keepSeparate: [2],
80
+ reasoning: "0 and 1 express same preference, 2 is different",
81
+ }));
82
+
83
+ const decision = await evaluateCluster(llm, entries);
84
+
85
+ expect(decision.mergeGroups.length).toBe(1);
86
+ expect(decision.mergeGroups[0]).toEqual([0, 1]);
87
+ expect(decision.keepSeparate).toEqual([2]);
88
+ });
89
+
90
+ it("returns conservative fallback on LLM failure", async () => {
91
+ const entries = [makeEntry("a", "text a"), makeEntry("b", "text b")];
92
+ const llm = {
93
+ async synthesizeFragments() { throw new Error("LLM down"); },
94
+ } as any;
95
+
96
+ const decision = await evaluateCluster(llm, entries);
97
+
98
+ expect(decision.mergeGroups.length).toBe(0);
99
+ expect(decision.keepSeparate.length).toBe(2);
100
+ });
101
+
102
+ it("returns conservative fallback on null response", async () => {
103
+ const entries = [makeEntry("a", "text a"), makeEntry("b", "text b")];
104
+ const llm = createMockLLM("");
105
+
106
+ // Empty string → null synthesis → fallback
107
+ const decision = await evaluateCluster({ async synthesizeFragments() { return null; } } as any, entries);
108
+
109
+ expect(decision.mergeGroups.length).toBe(0);
110
+ });
111
+
112
+ it("handles single entry", async () => {
113
+ const entries = [makeEntry("a", "only one")];
114
+ const llm = createMockLLM("{}");
115
+
116
+ const decision = await evaluateCluster(llm, entries);
117
+
118
+ expect(decision.mergeGroups.length).toBe(0);
119
+ expect(decision.keepSeparate).toEqual([0]);
120
+ });
121
+
122
+ it("filters invalid indices from LLM response", async () => {
123
+ const entries = [makeEntry("a", "text a"), makeEntry("b", "text b")];
124
+ const llm = createMockLLM(JSON.stringify({
125
+ mergeGroups: [[0, 5]], // index 5 out of range
126
+ keepSeparate: [0, 1],
127
+ reasoning: "test",
128
+ }));
129
+
130
+ const decision = await evaluateCluster(llm, entries);
131
+
132
+ // Invalid group should be filtered out
133
+ expect(decision.mergeGroups.length).toBe(0);
134
+ });
135
+ });
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // executeMergeDecisions
139
+ // ---------------------------------------------------------------------------
140
+
141
+ describe("executeMergeDecisions", () => {
142
+ it("creates version groups for merge groups", async () => {
143
+ const entries = [
144
+ makeEntry("a", "dark mode pref", 0.9),
145
+ makeEntry("b", "dark theme pref", 0.5),
146
+ makeEntry("c", "vim pref", 0.7),
147
+ ];
148
+ const store = createMockStore(entries);
149
+
150
+ const decision = {
151
+ mergeGroups: [[0, 1]],
152
+ keepSeparate: [2],
153
+ reasoning: "similar preferences",
154
+ };
155
+
156
+ const mergeCount = await executeMergeDecisions(store as any, entries, decision, "project:test");
157
+
158
+ expect(mergeCount).toBe(1);
159
+ // Both a and b should now have version_group
160
+ const metaA = JSON.parse(store.data.get("a")!.metadata);
161
+ const metaB = JSON.parse(store.data.get("b")!.metadata);
162
+ expect(metaA.version_group).toBeTruthy();
163
+ expect(metaB.version_group).toBe(metaA.version_group);
164
+ });
165
+
166
+ it("handles empty merge groups", async () => {
167
+ const entries = [makeEntry("a", "text a")];
168
+ const store = createMockStore(entries);
169
+
170
+ const decision = {
171
+ mergeGroups: [],
172
+ keepSeparate: [0],
173
+ reasoning: "nothing to merge",
174
+ };
175
+
176
+ const mergeCount = await executeMergeDecisions(store as any, entries, decision, "project:test");
177
+
178
+ expect(mergeCount).toBe(0);
179
+ });
180
+
181
+ it("handles multi-entry merge group", async () => {
182
+ const entries = [
183
+ makeEntry("a", "pref 1", 0.9),
184
+ makeEntry("b", "pref 2", 0.7),
185
+ makeEntry("c", "pref 3", 0.5),
186
+ ];
187
+ const store = createMockStore(entries);
188
+
189
+ const decision = {
190
+ mergeGroups: [[0, 1, 2]],
191
+ keepSeparate: [],
192
+ reasoning: "all same",
193
+ };
194
+
195
+ const mergeCount = await executeMergeDecisions(store as any, entries, decision, "project:test");
196
+
197
+ // a is canonical, b and c are members → 2 merges
198
+ expect(mergeCount).toBe(2);
199
+ });
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // isLLMConsolidationEnabled
204
+ // ---------------------------------------------------------------------------
205
+
206
+ describe("isLLMConsolidationEnabled", () => {
207
+ const originalEnv = process.env.RECALLNEST_LLM_CONSOLIDATION;
208
+
209
+ afterEach(() => {
210
+ if (originalEnv !== undefined) {
211
+ process.env.RECALLNEST_LLM_CONSOLIDATION = originalEnv;
212
+ } else {
213
+ delete process.env.RECALLNEST_LLM_CONSOLIDATION;
214
+ }
215
+ });
216
+
217
+ it("returns false when not set", () => {
218
+ delete process.env.RECALLNEST_LLM_CONSOLIDATION;
219
+ expect(isLLMConsolidationEnabled()).toBe(false);
220
+ });
221
+
222
+ it("returns true when set to 'true'", () => {
223
+ process.env.RECALLNEST_LLM_CONSOLIDATION = "true";
224
+ expect(isLLMConsolidationEnabled()).toBe(true);
225
+ });
226
+ });
@@ -0,0 +1,174 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { runDream } from "../dream-pipeline.js";
7
+ import { maybeRunGc, resetGcTimestamp, type AutoGcConfig, DEFAULT_AUTO_GC_CONFIG } from "../auto-gc.js";
8
+ import { lockPathForKey } from "../distill-lock.js";
9
+ import { incrementWriteCount, getWriteCount } from "../activity-counter.js";
10
+
11
+ // Isolate dream's activity-counter reads/writes to a temp file so tests never touch the
12
+ // repo's data/activity-stats.json (which the production dream scheduler reads).
13
+ const DREAM_STATS = join(mkdtempSync(join(tmpdir(), "rn-dream-stats-")), "activity-stats.json");
14
+
15
+ /**
16
+ * P0-1 接线回归:dream/gc 维护入口接了跨进程锁。用「预写一个当前进程 PID 的锁文件」
17
+ * 模拟「另一进程持有锁」(同进程 PID 恒活,acquireLock 判定为被占)。
18
+ */
19
+
20
+ const createdLocks: string[] = [];
21
+ function holdLock(key: string): string {
22
+ const p = lockPathForKey(key);
23
+ mkdirSync(dirname(p), { recursive: true });
24
+ writeFileSync(p, String(process.pid), "utf-8"); // current pid ⇒ acquireLock sees it as a live holder
25
+ createdLocks.push(p);
26
+ return p;
27
+ }
28
+
29
+ afterEach(() => {
30
+ while (createdLocks.length > 0) {
31
+ try {
32
+ rmSync(createdLocks.pop()!, { force: true });
33
+ } catch {
34
+ /* ignore */
35
+ }
36
+ }
37
+ resetGcTimestamp();
38
+ });
39
+
40
+ describe("runDream cross-process lock", () => {
41
+ it("skips (ran:false, locked) and does not run the body when the scope lock is held", async () => {
42
+ const scope = "project:dream-lock-test";
43
+ holdLock(`dream-${scope}`);
44
+
45
+ let bodyRan = false;
46
+ const store = {
47
+ stats: async () => {
48
+ bodyRan = true;
49
+ return { totalCount: 100, scopeCounts: {}, categoryCounts: {} };
50
+ },
51
+ list: async () => [],
52
+ listPage: async () => [],
53
+ update: async () => null,
54
+ patchMetadata: async () => null,
55
+ };
56
+
57
+ const result = await runDream({
58
+ store: store as any,
59
+ llm: null,
60
+ embedder: { embedPassage: async () => [0, 0, 0] },
61
+ scope,
62
+ force: true,
63
+ activityStatsPath: DREAM_STATS,
64
+ });
65
+
66
+ expect(result.ran).toBe(false);
67
+ expect(result.reason).toBe("locked_by_another_process");
68
+ expect(bodyRan).toBe(false); // runDreamInner never entered
69
+ });
70
+
71
+ it("skips its consolidation phase when the scope's consolidate lock is held", async () => {
72
+ const scope = "project:dream-consolidate-test";
73
+ holdLock(`consolidate-${scope}`); // simulate a standalone consolidate_memories running
74
+
75
+ const activeMeta = JSON.stringify({
76
+ evolution: { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, validFrom: Date.now(), validUntil: null },
77
+ });
78
+ const entries = [1, 2, 3].map((i) => ({
79
+ id: `dc-e${i}`, text: `t${i}`, vector: [1, 0, 0], category: "events", scope,
80
+ importance: 0.5, timestamp: Date.now(), metadata: activeMeta, language: "en", fts_text: `t${i}`,
81
+ }));
82
+ const store = {
83
+ stats: async () => ({ totalCount: 100, scopeCounts: {}, categoryCounts: {} }),
84
+ list: async () => entries,
85
+ listPage: async () => [],
86
+ update: async () => null,
87
+ patchMetadata: async () => null,
88
+ getById: async () => null,
89
+ vectorSearch: async () => [],
90
+ };
91
+
92
+ const result = await runDream({
93
+ store: store as any,
94
+ llm: null,
95
+ embedder: { embedPassage: async () => [0, 0, 0] },
96
+ scope,
97
+ force: true,
98
+ activityStatsPath: DREAM_STATS,
99
+ });
100
+
101
+ // Dream itself ran (its own dream lock was free), but the consolidation phase saw the
102
+ // held consolidate lock and skipped rather than running concurrently.
103
+ expect(result.ran).toBe(true);
104
+ const consolidatePhase = result.phases.find((p) => p.phase === "consolidate");
105
+ expect(consolidatePhase?.detail).toContain("skipped");
106
+ });
107
+
108
+ it("resets the scope's write counter after a run (no perpetual self-triggering)", async () => {
109
+ const scope = "project:dream-reset-test";
110
+ const statsPath = join(mkdtempSync(join(tmpdir(), "rn-reset-")), "activity-stats.json");
111
+ // Pre-load above threshold, standing in for accumulated writes (incl. any dream self-writes).
112
+ incrementWriteCount(scope, 15, { statsPath });
113
+ expect(getWriteCount(scope, { statsPath })).toBe(15);
114
+
115
+ const store = {
116
+ stats: async () => ({ totalCount: 5, scopeCounts: {}, categoryCounts: {} }),
117
+ list: async () => [], // no active entries → completed_early path (still resets)
118
+ listPage: async () => [],
119
+ update: async () => null,
120
+ patchMetadata: async () => null,
121
+ getById: async () => null,
122
+ vectorSearch: async () => [],
123
+ };
124
+ const result = await runDream({
125
+ store: store as any,
126
+ llm: null,
127
+ embedder: { embedPassage: async () => [0, 0, 0] },
128
+ scope,
129
+ force: true,
130
+ activityStatsPath: statsPath,
131
+ });
132
+
133
+ expect(result.ran).toBe(true);
134
+ // Reset at dream's end clears the counter, so dream won't immediately self-retrigger —
135
+ // fresh external writes must accumulate past the threshold again.
136
+ expect(getWriteCount(scope, { statsPath })).toBe(0);
137
+ });
138
+ });
139
+
140
+ describe("maybeRunGc cross-process lock + throttle", () => {
141
+ function makeStore(totalCount: number) {
142
+ return {
143
+ stats: async () => ({ totalCount, scopeCounts: {}, categoryCounts: {} }),
144
+ listPage: async () => [] as unknown[],
145
+ update: async () => null,
146
+ list: async () => [],
147
+ } as any;
148
+ }
149
+
150
+ it("skips (locked) when another process holds the gc run lock", async () => {
151
+ resetGcTimestamp();
152
+ holdLock("gc-run");
153
+ const config: AutoGcConfig = { ...DEFAULT_AUTO_GC_CONFIG, minMemoryCount: 1, minHoursSinceLastGc: 24 };
154
+ const result = await maybeRunGc(makeStore(10), config);
155
+ expect(result.triggered).toBe(false);
156
+ expect(result.reason).toBe("locked_by_another_process");
157
+ });
158
+
159
+ it("throttles a second run within the window, then runs again after reset", async () => {
160
+ resetGcTimestamp();
161
+ const config: AutoGcConfig = { ...DEFAULT_AUTO_GC_CONFIG, minMemoryCount: 1, minHoursSinceLastGc: 24 };
162
+
163
+ const first = await maybeRunGc(makeStore(10), config);
164
+ expect(first.triggered).toBe(true);
165
+
166
+ const second = await maybeRunGc(makeStore(10), config);
167
+ expect(second.triggered).toBe(false);
168
+ expect(second.reason).toBe("too_soon");
169
+
170
+ resetGcTimestamp();
171
+ const third = await maybeRunGc(makeStore(10), config);
172
+ expect(third.triggered).toBe(true);
173
+ });
174
+ });
@@ -0,0 +1,288 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import type { RetrievalResult } from "../retriever.js";
4
+ import { formatBriefResults, formatFullResults } from "../memory-output.js";
5
+ import { persistMemoryBatch } from "../capture-engine.js";
6
+
7
+ // ============================================================================
8
+ // Helpers
9
+ // ============================================================================
10
+
11
+ function makeResult(overrides: {
12
+ id?: string;
13
+ text?: string;
14
+ score?: number;
15
+ metadata?: string;
16
+ category?: string;
17
+ importance?: number;
18
+ timestamp?: number;
19
+ } = {}): RetrievalResult {
20
+ return {
21
+ entry: {
22
+ id: overrides.id ?? "abcdef12-3456-7890-abcd-ef1234567890",
23
+ text: overrides.text ?? "Default memory text for testing purposes",
24
+ vector: [0.1, 0.2, 0.3],
25
+ category: overrides.category ?? "events",
26
+ scope: "project:test",
27
+ importance: overrides.importance ?? 0.7,
28
+ timestamp: overrides.timestamp ?? 1_700_000_000_000,
29
+ metadata: overrides.metadata,
30
+ },
31
+ score: overrides.score ?? 0.85,
32
+ sources: {
33
+ vector: { score: 0.8, rank: 1 },
34
+ fused: { score: overrides.score ?? 0.85 },
35
+ },
36
+ };
37
+ }
38
+
39
+ function createDeps() {
40
+ const storedEntries: Record<string, unknown>[] = [];
41
+ const conflicts: Record<string, unknown>[] = [];
42
+ let seq = 1;
43
+
44
+ return {
45
+ storedEntries,
46
+ conflicts,
47
+ deps: {
48
+ embedder: {
49
+ async embedPassage(_text: string) {
50
+ return [1, 0, 0];
51
+ },
52
+ },
53
+ store: {
54
+ async store(entry: Record<string, unknown>) {
55
+ const stored = {
56
+ ...entry,
57
+ id: `00000000-0000-0000-0000-${String(seq).padStart(12, "0")}`,
58
+ timestamp: 1_700_000_000_000 + seq,
59
+ };
60
+ seq += 1;
61
+ storedEntries.push(stored);
62
+ return stored;
63
+ },
64
+ async search(_params: Record<string, unknown>) {
65
+ return [];
66
+ },
67
+ async getByScope(_scope: string) {
68
+ return [];
69
+ },
70
+ },
71
+ conflictStore: {
72
+ async save(record: Record<string, unknown>) {
73
+ conflicts.push(record);
74
+ return record;
75
+ },
76
+ },
77
+ kgExtractor: null,
78
+ },
79
+ };
80
+ }
81
+
82
+ // ============================================================================
83
+ // MCP-1: list_tools
84
+ // ============================================================================
85
+
86
+ describe("MCP-1: list_tools tier discovery", () => {
87
+ // We test the tier filtering logic directly since we can't easily instantiate the MCP server.
88
+ // The TOOL_TIERS map and tier logic is exercised here.
89
+
90
+ const TOOL_TIERS: Record<string, string> = {
91
+ resume_context: "core",
92
+ search_memory: "core",
93
+ store_memory: "core",
94
+ checkpoint_session: "core",
95
+ latest_checkpoint: "core",
96
+ list_tools: "core",
97
+ set_reminder: "core",
98
+ batch_store: "advanced",
99
+ auto_capture: "advanced",
100
+ store_case: "advanced",
101
+ store_workflow_pattern: "advanced",
102
+ promote_memory: "advanced",
103
+ explain_memory: "advanced",
104
+ distill_memory: "advanced",
105
+ brief_memory: "advanced",
106
+ pin_memory: "advanced",
107
+ list_assets: "advanced",
108
+ list_pins: "advanced",
109
+ memory_stats: "advanced",
110
+ memory_drill_down: "advanced",
111
+ export_memory: "advanced",
112
+ store_skill: "advanced",
113
+ retrieve_skill: "advanced",
114
+ scan_skill_promotions: "governance",
115
+ workflow_observe: "governance",
116
+ workflow_health: "governance",
117
+ };
118
+
119
+ function listToolsForTier(requestedTier: "core" | "advanced" | "full"): string[] {
120
+ const tierOrder: Record<string, number> = { core: 0, advanced: 1, governance: 2 };
121
+ const maxOrder = requestedTier === "full" ? 2 : tierOrder[requestedTier] ?? 1;
122
+ return Object.entries(TOOL_TIERS)
123
+ .filter(([, toolTier]) => (tierOrder[toolTier] ?? 999) <= maxOrder)
124
+ .map(([name]) => name);
125
+ }
126
+
127
+ it("returns only core tier tools when tier=core", () => {
128
+ const tools = listToolsForTier("core");
129
+ expect(tools).toContain("resume_context");
130
+ expect(tools).toContain("search_memory");
131
+ expect(tools).toContain("list_tools");
132
+ expect(tools).not.toContain("auto_capture");
133
+ expect(tools).not.toContain("workflow_observe");
134
+ // Core should have exactly our known core tools
135
+ for (const t of tools) {
136
+ expect(TOOL_TIERS[t]).toBe("core");
137
+ }
138
+ });
139
+
140
+ it("returns core + advanced tools when tier=advanced", () => {
141
+ const tools = listToolsForTier("advanced");
142
+ // Should include core
143
+ expect(tools).toContain("resume_context");
144
+ expect(tools).toContain("search_memory");
145
+ expect(tools).toContain("list_tools");
146
+ // Should include advanced
147
+ expect(tools).toContain("auto_capture");
148
+ expect(tools).toContain("batch_store");
149
+ expect(tools).toContain("explain_memory");
150
+ // Should NOT include governance
151
+ expect(tools).not.toContain("workflow_observe");
152
+ expect(tools).not.toContain("scan_skill_promotions");
153
+ });
154
+
155
+ it("returns all tools when tier=full", () => {
156
+ const tools = listToolsForTier("full");
157
+ expect(tools).toContain("resume_context");
158
+ expect(tools).toContain("auto_capture");
159
+ expect(tools).toContain("workflow_observe");
160
+ expect(tools).toContain("scan_skill_promotions");
161
+ expect(tools.length).toBe(Object.keys(TOOL_TIERS).length);
162
+ });
163
+ });
164
+
165
+ // ============================================================================
166
+ // MCP-2: formatBriefResults and formatFullResults
167
+ // ============================================================================
168
+
169
+ describe("MCP-2: formatBriefResults", () => {
170
+ it("returns no results message for empty array", () => {
171
+ const result = formatBriefResults([], { query: "test" });
172
+ expect(result).toBe("No results found.");
173
+ });
174
+
175
+ it("renders correct brief format", () => {
176
+ const results = [
177
+ makeResult({ id: "aabbccdd-1111-2222-3333-444455556666", score: 0.92, text: "User prefers TypeScript for all projects" }),
178
+ makeResult({ id: "11223344-aaaa-bbbb-cccc-ddddeeeeffff", score: 0.78, text: "Project RecallNest uses LanceDB" }),
179
+ ];
180
+ const output = formatBriefResults(results, { query: "typescript" });
181
+ expect(output).toContain("Query: typescript");
182
+ expect(output).toContain("Hits: 2");
183
+ expect(output).toContain("#1 aabbccdd 92.0%");
184
+ expect(output).toContain("#2 11223344 78.0%");
185
+ expect(output).toContain("User prefers TypeScript");
186
+ });
187
+
188
+ it("uses l0_abstract from metadata when available", () => {
189
+ const meta = JSON.stringify({ l0_abstract: "Short L0 summary for this memory" });
190
+ const results = [
191
+ makeResult({ metadata: meta, text: "Very long original text that should not appear in brief mode because the l0_abstract is available" }),
192
+ ];
193
+ const output = formatBriefResults(results, { query: "test" });
194
+ expect(output).toContain("Short L0 summary");
195
+ expect(output).not.toContain("Very long original text");
196
+ });
197
+
198
+ it("each brief result line is at most 120 characters", () => {
199
+ const results = [
200
+ makeResult({ text: "A".repeat(200) }),
201
+ makeResult({ id: "zzzzzzzz-0000-0000-0000-000000000000", text: "Short text" }),
202
+ ];
203
+ const output = formatBriefResults(results, { query: "q" });
204
+ const lines = output.split("\n");
205
+ // Only check result lines (starting with #)
206
+ for (const line of lines) {
207
+ if (line.startsWith("#")) {
208
+ expect(line.length).toBeLessThanOrEqual(120);
209
+ }
210
+ }
211
+ });
212
+ });
213
+
214
+ describe("MCP-2: formatFullResults", () => {
215
+ it("includes metadata details in full mode", () => {
216
+ const meta = JSON.stringify({ evolutionStatus: "stable", accessCount: 5, tags: ["ts", "dev"] });
217
+ const results = [
218
+ makeResult({ metadata: meta, importance: 0.85 }),
219
+ ];
220
+ const output = formatFullResults(results, { query: "test", profile: "default" });
221
+ expect(output).toContain("meta :");
222
+ expect(output).toContain("evolution=stable");
223
+ expect(output).toContain("accessCount=5");
224
+ expect(output).toContain("importance=0.85");
225
+ expect(output).toContain("tags=[ts, dev]");
226
+ });
227
+
228
+ it("shows dash for missing metadata fields", () => {
229
+ const results = [makeResult({})];
230
+ const output = formatFullResults(results, { query: "q", profile: "default" });
231
+ expect(output).toContain("evolution=-");
232
+ expect(output).toContain("accessCount=-");
233
+ expect(output).toContain("tags=[-]");
234
+ });
235
+ });
236
+
237
+ // ============================================================================
238
+ // MCP-3: batch_store via persistMemoryBatch
239
+ // ============================================================================
240
+
241
+ describe("MCP-3: batch_store", () => {
242
+ it("stores multiple memories and returns all records", async () => {
243
+ const { deps, storedEntries } = createDeps();
244
+ const stored = await persistMemoryBatch(deps as Parameters<typeof persistMemoryBatch>[0], {
245
+ scope: "project:test",
246
+ source: "agent",
247
+ defaultImportance: 0.7,
248
+ memories: [
249
+ { text: "Memory one about TypeScript preferences", category: "preferences" },
250
+ { text: "Memory two about project structure", category: "entities" },
251
+ { text: "Memory three about debugging workflow", category: "patterns" },
252
+ ],
253
+ });
254
+
255
+ expect(stored).toHaveLength(3);
256
+ expect(storedEntries).toHaveLength(3);
257
+ expect(stored[0].disposition).toBe("stored");
258
+ expect(stored[1].disposition).toBe("stored");
259
+ expect(stored[2].disposition).toBe("stored");
260
+ });
261
+
262
+ it("returns correct disposition counts", async () => {
263
+ const { deps } = createDeps();
264
+ const stored = await persistMemoryBatch(deps as Parameters<typeof persistMemoryBatch>[0], {
265
+ scope: "project:test",
266
+ source: "agent",
267
+ defaultImportance: 0.7,
268
+ memories: [
269
+ { text: "First memory item" },
270
+ { text: "Second memory item" },
271
+ ],
272
+ });
273
+
274
+ // Count dispositions the same way the MCP handler does
275
+ const counts = { new: 0, deduped: 0, updated: 0 };
276
+ for (const r of stored) {
277
+ if (r.disposition === "deduped") counts.deduped++;
278
+ else if (r.disposition === "updated") counts.updated++;
279
+ else counts.new++;
280
+ }
281
+
282
+ expect(counts.new).toBe(2);
283
+ expect(counts.deduped).toBe(0);
284
+ expect(counts.updated).toBe(0);
285
+ expect(`Stored ${stored.length} memories (${counts.new} new, ${counts.deduped} deduped, ${counts.updated} updated)`)
286
+ .toBe("Stored 2 memories (2 new, 0 deduped, 0 updated)");
287
+ });
288
+ });