recallnest 1.4.1 → 2.2.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 (166) hide show
  1. package/.dockerignore +11 -0
  2. package/CLAUDE.md +7 -3
  3. package/Dockerfile +20 -0
  4. package/README.md +202 -130
  5. package/README_CN.md +263 -190
  6. package/ROADMAP.md +128 -81
  7. package/SKILL.md +250 -0
  8. package/assets/dashboard.png +0 -0
  9. package/assets/knowledge-graph.png +0 -0
  10. package/assets/screenshots/ui-full.png +0 -0
  11. package/assets/screenshots/ui-overview.png +0 -0
  12. package/assets/screenshots/ui-search.png +0 -0
  13. package/assets/ui/app.js +227 -3
  14. package/assets/ui/index.html +81 -11
  15. package/assets/ui/styles.css +178 -2
  16. package/bin/recallnest.mjs +62 -0
  17. package/bun.lock +337 -0
  18. package/daily-recall.sh +23 -0
  19. package/daily-reflections/2026-04-02.md +35 -0
  20. package/daily-reflections/2026-04-03.md +37 -0
  21. package/daily-reflections/2026-04-04.md +37 -0
  22. package/daily-reflections/2026-04-05.md +35 -0
  23. package/daily-reflections/2026-04-06.md +36 -0
  24. package/daily-reflections/2026-04-07.md +35 -0
  25. package/daily-reflections/2026-04-08.md +35 -0
  26. package/daily-reflections/2026-04-12.md +35 -0
  27. package/data/alias-map.example.json +22 -0
  28. package/docs/plans/2026-04-01-recall-boost.md +100 -0
  29. package/docs/superpowers/plans/2026-04-11-constructive-retrieval-emotion.md +1195 -0
  30. package/docs/superpowers/specs/2026-04-11-constructive-retrieval-emotion-design.md +398 -0
  31. package/glama.json +6 -0
  32. package/integrations/setup-all.sh +31 -0
  33. package/package.json +10 -3
  34. package/plan-philosophy-upgrades.md +363 -0
  35. package/plan-product-upgrades.md +155 -0
  36. package/plan.md +140 -0
  37. package/research-memory-evolution.md +440 -0
  38. package/research-rl-agent-memory.md +330 -0
  39. package/scripts/backfill-anchors.ts +130 -0
  40. package/scripts/diagnose-vector-search.ts +132 -0
  41. package/scripts/migrate-language.ts +75 -0
  42. package/scripts/verify-emotion-decay.ts +148 -0
  43. package/src/__tests__/activity-counter.test.ts +96 -0
  44. package/src/__tests__/admission-control.test.ts +206 -0
  45. package/src/__tests__/anchor-generator.test.ts +67 -0
  46. package/src/__tests__/babel-integration.test.ts +139 -0
  47. package/src/__tests__/capture-engine.test.ts +5 -3
  48. package/src/__tests__/cascade-forget.test.ts +1 -1
  49. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  50. package/src/__tests__/circuit-breaker.test.ts +99 -0
  51. package/src/__tests__/cluster-consolidation.test.ts +712 -0
  52. package/src/__tests__/confidence-metadata.test.ts +125 -0
  53. package/src/__tests__/confidence-tracker.test.ts +53 -0
  54. package/src/__tests__/consolidation-engine.test.ts +1 -1
  55. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  56. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  57. package/src/__tests__/context-reconstructor.test.ts +65 -0
  58. package/src/__tests__/conversation-importer.test.ts +692 -0
  59. package/src/__tests__/data-checkup.test.ts +225 -0
  60. package/src/__tests__/decay-exemption.test.ts +84 -0
  61. package/src/__tests__/dedup-guard.test.ts +39 -0
  62. package/src/__tests__/distill-lock.test.ts +176 -0
  63. package/src/__tests__/dream-pipeline.test.ts +232 -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__/ethics-layer.test.ts +476 -0
  69. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  70. package/src/__tests__/frequency-tracker.test.ts +113 -0
  71. package/src/__tests__/graph-export.test.ts +489 -0
  72. package/src/__tests__/interference-detector.test.ts +203 -0
  73. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  74. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +243 -0
  75. package/src/__tests__/memory-evolution.test.ts +271 -0
  76. package/src/__tests__/memory-lint.test.ts +271 -0
  77. package/src/__tests__/narrative-architecture.test.ts +456 -0
  78. package/src/__tests__/pending-review.test.ts +77 -0
  79. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  80. package/src/__tests__/prediction-engine.test.ts +349 -0
  81. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  82. package/src/__tests__/preference-slots.test.ts +99 -0
  83. package/src/__tests__/query-expander-alias.test.ts +89 -0
  84. package/src/__tests__/recall-governor.test.ts +227 -0
  85. package/src/__tests__/resume-context.test.ts +156 -1
  86. package/src/__tests__/salience-scoring.test.ts +106 -0
  87. package/src/__tests__/session-distiller.test.ts +611 -0
  88. package/src/__tests__/skill-engine.test.ts +546 -0
  89. package/src/__tests__/skill-promotion.test.ts +349 -0
  90. package/src/__tests__/supersede-chain.test.ts +154 -0
  91. package/src/__tests__/temporal-validity.test.ts +160 -0
  92. package/src/__tests__/topic-tag.test.ts +113 -0
  93. package/src/__tests__/trusted-memory.test.ts +828 -0
  94. package/src/__tests__/write-verifier.test.ts +122 -0
  95. package/src/access-tracker.ts +3 -2
  96. package/src/activity-counter.ts +109 -0
  97. package/src/adaptive-retrieval.ts +6 -5
  98. package/src/admission-control.ts +163 -0
  99. package/src/anchor-generator.ts +80 -0
  100. package/src/api-server.ts +10 -0
  101. package/src/audit-log.ts +122 -0
  102. package/src/auto-gc.ts +98 -29
  103. package/src/auto-recall.ts +33 -4
  104. package/src/capture-engine.ts +372 -23
  105. package/src/cascade-forget.ts +3 -2
  106. package/src/cli.ts +107 -5
  107. package/src/compat.ts +22 -0
  108. package/src/confidence-tracker.ts +210 -47
  109. package/src/conflict-store.ts +12 -3
  110. package/src/consolidation-engine.ts +296 -8
  111. package/src/context-collapse-renderer.ts +222 -0
  112. package/src/context-composer-stable-selection.ts +5 -1
  113. package/src/context-composer.ts +239 -4
  114. package/src/context-reconstructor.ts +404 -0
  115. package/src/conversation-importer.ts +441 -0
  116. package/src/data-checkup.ts +232 -0
  117. package/src/decay-engine.ts +84 -0
  118. package/src/distill-lock.ts +122 -0
  119. package/src/doctor.ts +8 -10
  120. package/src/dream-pipeline.ts +227 -0
  121. package/src/emotion-detector.ts +90 -0
  122. package/src/eval.ts +3 -2
  123. package/src/forget-engine.ts +269 -0
  124. package/src/frequency-tracker.ts +175 -0
  125. package/src/graph-export.ts +644 -0
  126. package/src/ingest.ts +36 -6
  127. package/src/interference-detector.ts +187 -0
  128. package/src/kg-extractor.ts +15 -24
  129. package/src/kg-store.ts +2 -1
  130. package/src/language-hook.ts +67 -0
  131. package/src/llm-client.ts +289 -6
  132. package/src/mcp-server.ts +707 -80
  133. package/src/memory-assets.ts +22 -5
  134. package/src/memory-evolution.ts +365 -0
  135. package/src/memory-lint.ts +429 -0
  136. package/src/memory-output.ts +69 -0
  137. package/src/memory-schema.ts +74 -2
  138. package/src/narrative-schema.ts +79 -0
  139. package/src/narrative-tagger.ts +164 -0
  140. package/src/pii-detector.ts +98 -0
  141. package/src/post-retrieval-filter.ts +76 -0
  142. package/src/prediction-engine.ts +253 -0
  143. package/src/preference-slots.ts +65 -2
  144. package/src/prospective-memory.ts +215 -0
  145. package/src/query-expander.ts +67 -0
  146. package/src/recall-governor.ts +183 -0
  147. package/src/retention-policy.ts +90 -0
  148. package/src/retriever.ts +828 -55
  149. package/src/rif.ts +34 -8
  150. package/src/runtime-config.ts +23 -8
  151. package/src/scope-inventory.ts +4 -3
  152. package/src/scope-policy.ts +4 -0
  153. package/src/session-distiller.ts +307 -0
  154. package/src/session-output.ts +40 -0
  155. package/src/session-schema.ts +37 -0
  156. package/src/session-store.ts +7 -2
  157. package/src/skill-engine.ts +208 -0
  158. package/src/skill-promotion.ts +293 -0
  159. package/src/skill-schema.ts +39 -0
  160. package/src/store.ts +96 -10
  161. package/src/topic-tag.ts +66 -0
  162. package/src/tracker.ts +3 -1
  163. package/src/ui-server.ts +80 -8
  164. package/src/workflow-observation-scope-review.ts +2 -1
  165. package/src/workflow-observation-store.ts +2 -1
  166. package/src/write-verifier.ts +126 -0
@@ -0,0 +1,271 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ defaultEvolution,
4
+ parseEvolution,
5
+ patchEvolution,
6
+ isActiveMemory,
7
+ recordAccess,
8
+ buildSupersedeMetadata,
9
+ buildConsolidatedMetadata,
10
+ buildArchivedMetadata,
11
+ computeDecayScore,
12
+ type EvolutionMetadata,
13
+ } from "../memory-evolution.js";
14
+
15
+ describe("memory-evolution", () => {
16
+ // -----------------------------------------------------------------------
17
+ // defaultEvolution
18
+ // -----------------------------------------------------------------------
19
+ describe("defaultEvolution", () => {
20
+ test("returns active status with version 1", () => {
21
+ const evo = defaultEvolution(1000);
22
+ expect(evo.status).toBe("active");
23
+ expect(evo.version).toBe(1);
24
+ expect(evo.accessCount).toBe(0);
25
+ expect(evo.lastAccessedAt).toBeNull();
26
+ expect(evo.supersededBy).toBeNull();
27
+ expect(evo.consolidatedInto).toBeNull();
28
+ expect(evo.sourceMemories).toEqual([]);
29
+ expect(evo.validFrom).toBe(1000);
30
+ expect(evo.validUntil).toBeNull();
31
+ });
32
+
33
+ test("uses Date.now() when no timestamp provided", () => {
34
+ const before = Date.now();
35
+ const evo = defaultEvolution();
36
+ expect(evo.validFrom).toBeGreaterThanOrEqual(before);
37
+ expect(evo.validFrom).toBeLessThanOrEqual(Date.now());
38
+ });
39
+ });
40
+
41
+ // -----------------------------------------------------------------------
42
+ // parseEvolution
43
+ // -----------------------------------------------------------------------
44
+ describe("parseEvolution", () => {
45
+ test("returns defaults for undefined metadata", () => {
46
+ const evo = parseEvolution(undefined, 2000);
47
+ expect(evo.status).toBe("active");
48
+ expect(evo.validFrom).toBe(2000);
49
+ });
50
+
51
+ test("returns defaults for empty string metadata", () => {
52
+ const evo = parseEvolution("", 2000);
53
+ expect(evo.status).toBe("active");
54
+ });
55
+
56
+ test("returns defaults for metadata without evolution field", () => {
57
+ const meta = JSON.stringify({ source: "manual", tags: [] });
58
+ const evo = parseEvolution(meta, 3000);
59
+ expect(evo.status).toBe("active");
60
+ expect(evo.validFrom).toBe(3000);
61
+ });
62
+
63
+ test("returns defaults for invalid JSON", () => {
64
+ const evo = parseEvolution("not json", 4000);
65
+ expect(evo.status).toBe("active");
66
+ expect(evo.validFrom).toBe(4000);
67
+ });
68
+
69
+ test("parses valid evolution metadata", () => {
70
+ const meta = JSON.stringify({
71
+ source: "agent",
72
+ evolution: {
73
+ status: "superseded",
74
+ version: 3,
75
+ accessCount: 42,
76
+ lastAccessedAt: 5000,
77
+ supersededBy: "abc-123",
78
+ consolidatedInto: null,
79
+ sourceMemories: ["x", "y"],
80
+ validFrom: 1000,
81
+ validUntil: 4000,
82
+ },
83
+ });
84
+ const evo = parseEvolution(meta);
85
+ expect(evo.status).toBe("superseded");
86
+ expect(evo.version).toBe(3);
87
+ expect(evo.accessCount).toBe(42);
88
+ expect(evo.lastAccessedAt).toBe(5000);
89
+ expect(evo.supersededBy).toBe("abc-123");
90
+ expect(evo.sourceMemories).toEqual(["x", "y"]);
91
+ expect(evo.validFrom).toBe(1000);
92
+ expect(evo.validUntil).toBe(4000);
93
+ });
94
+
95
+ test("fills missing fields with defaults", () => {
96
+ const meta = JSON.stringify({ evolution: { status: "archived" } });
97
+ const evo = parseEvolution(meta, 6000);
98
+ expect(evo.status).toBe("archived");
99
+ expect(evo.version).toBe(1);
100
+ expect(evo.accessCount).toBe(0);
101
+ expect(evo.validFrom).toBe(6000);
102
+ });
103
+ });
104
+
105
+ // -----------------------------------------------------------------------
106
+ // patchEvolution
107
+ // -----------------------------------------------------------------------
108
+ describe("patchEvolution", () => {
109
+ test("creates evolution on empty metadata", () => {
110
+ const patched = patchEvolution(undefined, { status: "archived" });
111
+ const parsed = JSON.parse(patched);
112
+ expect(parsed.evolution.status).toBe("archived");
113
+ });
114
+
115
+ test("merges into existing evolution", () => {
116
+ const original = JSON.stringify({
117
+ source: "manual",
118
+ evolution: { status: "active", version: 1, accessCount: 5 },
119
+ });
120
+ const patched = patchEvolution(original, { accessCount: 6, lastAccessedAt: 9000 });
121
+ const parsed = JSON.parse(patched);
122
+ expect(parsed.source).toBe("manual");
123
+ expect(parsed.evolution.status).toBe("active");
124
+ expect(parsed.evolution.version).toBe(1);
125
+ expect(parsed.evolution.accessCount).toBe(6);
126
+ expect(parsed.evolution.lastAccessedAt).toBe(9000);
127
+ });
128
+
129
+ test("preserves non-evolution fields", () => {
130
+ const original = JSON.stringify({ source: "agent", tags: ["a"], canonicalKey: "k1" });
131
+ const patched = patchEvolution(original, { status: "superseded" });
132
+ const parsed = JSON.parse(patched);
133
+ expect(parsed.source).toBe("agent");
134
+ expect(parsed.tags).toEqual(["a"]);
135
+ expect(parsed.canonicalKey).toBe("k1");
136
+ });
137
+ });
138
+
139
+ // -----------------------------------------------------------------------
140
+ // isActiveMemory
141
+ // -----------------------------------------------------------------------
142
+ describe("isActiveMemory", () => {
143
+ test("true for undefined metadata (backward compat)", () => {
144
+ expect(isActiveMemory(undefined)).toBe(true);
145
+ });
146
+
147
+ test("true for metadata without evolution", () => {
148
+ expect(isActiveMemory(JSON.stringify({ source: "manual" }))).toBe(true);
149
+ });
150
+
151
+ test("true for active status", () => {
152
+ const meta = JSON.stringify({ evolution: { status: "active" } });
153
+ expect(isActiveMemory(meta)).toBe(true);
154
+ });
155
+
156
+ test("false for superseded", () => {
157
+ const meta = JSON.stringify({ evolution: { status: "superseded" } });
158
+ expect(isActiveMemory(meta)).toBe(false);
159
+ });
160
+
161
+ test("false for archived", () => {
162
+ const meta = JSON.stringify({ evolution: { status: "archived" } });
163
+ expect(isActiveMemory(meta)).toBe(false);
164
+ });
165
+
166
+ test("false for consolidated", () => {
167
+ const meta = JSON.stringify({ evolution: { status: "consolidated" } });
168
+ expect(isActiveMemory(meta)).toBe(false);
169
+ });
170
+ });
171
+
172
+ // -----------------------------------------------------------------------
173
+ // recordAccess
174
+ // -----------------------------------------------------------------------
175
+ describe("recordAccess", () => {
176
+ test("increments count from 0 on undefined metadata", () => {
177
+ const updated = recordAccess(undefined);
178
+ const evo = parseEvolution(updated);
179
+ expect(evo.accessCount).toBe(1);
180
+ expect(evo.lastAccessedAt).toBeGreaterThan(0);
181
+ });
182
+
183
+ test("increments existing count", () => {
184
+ const original = JSON.stringify({
185
+ evolution: { accessCount: 5, lastAccessedAt: 1000 },
186
+ });
187
+ const updated = recordAccess(original);
188
+ const evo = parseEvolution(updated);
189
+ expect(evo.accessCount).toBe(6);
190
+ expect(evo.lastAccessedAt!).toBeGreaterThan(1000);
191
+ });
192
+ });
193
+
194
+ // -----------------------------------------------------------------------
195
+ // buildSupersedeMetadata / buildConsolidatedMetadata / buildArchivedMetadata
196
+ // -----------------------------------------------------------------------
197
+ describe("supersede/consolidate/archive helpers", () => {
198
+ test("buildSupersedeMetadata sets status and links", () => {
199
+ const original = JSON.stringify({ source: "manual", evolution: defaultEvolution(1000) });
200
+ const result = buildSupersedeMetadata(original, "new-id-123");
201
+ const evo = parseEvolution(result);
202
+ expect(evo.status).toBe("superseded");
203
+ expect(evo.supersededBy).toBe("new-id-123");
204
+ expect(evo.validUntil).toBeGreaterThan(0);
205
+ });
206
+
207
+ test("buildConsolidatedMetadata sets status and link", () => {
208
+ const result = buildConsolidatedMetadata(undefined, "consolidated-id");
209
+ const evo = parseEvolution(result);
210
+ expect(evo.status).toBe("consolidated");
211
+ expect(evo.consolidatedInto).toBe("consolidated-id");
212
+ });
213
+
214
+ test("buildArchivedMetadata sets status", () => {
215
+ const result = buildArchivedMetadata(undefined);
216
+ const evo = parseEvolution(result);
217
+ expect(evo.status).toBe("archived");
218
+ });
219
+ });
220
+
221
+ // -----------------------------------------------------------------------
222
+ // computeDecayScore
223
+ // -----------------------------------------------------------------------
224
+ describe("computeDecayScore", () => {
225
+ test("brand new high-importance memory scores high", () => {
226
+ const now = Date.now();
227
+ const evo: EvolutionMetadata = {
228
+ ...defaultEvolution(now),
229
+ accessCount: 0,
230
+ };
231
+ const score = computeDecayScore(evo, 1.0, now);
232
+ // 0.2 * 1.0 (fresh) + 0.3 * 0 (no access) * 0.5 + 0.5 * 1.0 = 0.7 + small freq
233
+ expect(score).toBeGreaterThan(0.6);
234
+ expect(score).toBeLessThanOrEqual(1.0);
235
+ });
236
+
237
+ test("old unaccessed low-importance memory scores low", () => {
238
+ const now = Date.now();
239
+ const evo: EvolutionMetadata = {
240
+ ...defaultEvolution(now - 365 * 86_400_000), // 1 year old
241
+ accessCount: 0,
242
+ };
243
+ const score = computeDecayScore(evo, 0.1, now);
244
+ expect(score).toBeLessThan(0.2);
245
+ });
246
+
247
+ test("frequently accessed memory gets frequency boost", () => {
248
+ const now = Date.now();
249
+ const evoNoAccess: EvolutionMetadata = {
250
+ ...defaultEvolution(now - 30 * 86_400_000),
251
+ accessCount: 0,
252
+ };
253
+ const evoHighAccess: EvolutionMetadata = {
254
+ ...defaultEvolution(now - 30 * 86_400_000),
255
+ accessCount: 100,
256
+ lastAccessedAt: now - 86_400_000, // yesterday
257
+ };
258
+ const scoreNo = computeDecayScore(evoNoAccess, 0.5, now);
259
+ const scoreHigh = computeDecayScore(evoHighAccess, 0.5, now);
260
+ expect(scoreHigh).toBeGreaterThan(scoreNo);
261
+ });
262
+
263
+ test("importance dominates scoring (0.5 weight)", () => {
264
+ const now = Date.now();
265
+ const evo = defaultEvolution(now);
266
+ const lowImp = computeDecayScore(evo, 0.1, now);
267
+ const highImp = computeDecayScore(evo, 1.0, now);
268
+ expect(highImp - lowImp).toBeGreaterThan(0.3); // 0.5 * (1.0 - 0.1) = 0.45
269
+ });
270
+ });
271
+ });
@@ -0,0 +1,271 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { runMemoryLint, formatMemoryLintReport, computeHealthScore, type MemoryLintReport } from "../memory-lint.js";
3
+ import type { MemoryEntry, MemoryStore } from "../store.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeEntry(overrides: Partial<MemoryEntry> & { id: string }): MemoryEntry {
10
+ return {
11
+ text: "test memory",
12
+ vector: [1, 0, 0, 0, 0],
13
+ category: "events",
14
+ scope: "project:test",
15
+ importance: 0.7,
16
+ timestamp: Date.now(),
17
+ metadata: JSON.stringify({
18
+ evolution: {
19
+ status: "active",
20
+ version: 1,
21
+ accessCount: 0,
22
+ lastAccessedAt: null,
23
+ supersededBy: null,
24
+ supersedes: null,
25
+ evolutionNote: null,
26
+ consolidatedInto: null,
27
+ contributedToPattern: null,
28
+ sourceMemories: [],
29
+ validFrom: Date.now(),
30
+ validUntil: null,
31
+ },
32
+ }),
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function createMockStore(entries: MemoryEntry[]): Pick<MemoryStore, "list"> {
38
+ return {
39
+ async list() { return entries; },
40
+ } as Pick<MemoryStore, "list">;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // runMemoryLint
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe("runMemoryLint", () => {
48
+ it("returns all-clear for healthy data", async () => {
49
+ const entries = [
50
+ makeEntry({ id: "a1", text: "User prefers dark mode", category: "preferences", vector: [1, 0, 0, 0, 0] }),
51
+ makeEntry({ id: "a2", text: "Project uses Bun runtime", category: "entities", vector: [0, 1, 0, 0, 0] }),
52
+ ];
53
+ const store = createMockStore(entries);
54
+ const report = await runMemoryLint({ store });
55
+
56
+ expect(report.healthScore).toBe(100);
57
+ expect(report.findings).toHaveLength(0);
58
+ expect(report.summary.contradictions).toBe(0);
59
+ expect(report.summary.duplicates).toBe(0);
60
+ expect(report.totalScanned).toBe(2);
61
+ });
62
+
63
+ it("detects contradictions in same scope+category", async () => {
64
+ const entries = [
65
+ makeEntry({ id: "c1", text: "Always use Bun for scripts", scope: "project:x", category: "patterns" }),
66
+ makeEntry({ id: "c2", text: "Never use Bun for scripts", scope: "project:x", category: "patterns" }),
67
+ ];
68
+ const store = createMockStore(entries);
69
+ const report = await runMemoryLint({ store });
70
+
71
+ expect(report.summary.contradictions).toBeGreaterThanOrEqual(1);
72
+ const finding = report.findings.find(f => f.check === "contradiction");
73
+ expect(finding).toBeDefined();
74
+ expect(finding!.memoryIds).toContain("c1");
75
+ expect(finding!.memoryIds).toContain("c2");
76
+ });
77
+
78
+ it("detects duplicates by vector similarity", async () => {
79
+ const vec = [0.5, 0.5, 0.5, 0.5, 0.5];
80
+ const entries = [
81
+ makeEntry({ id: "d1", text: "Docker port is 4318", vector: vec, scope: "project:x", category: "entities" }),
82
+ makeEntry({ id: "d2", text: "Docker port is 4318 config", vector: vec, scope: "project:x", category: "entities" }),
83
+ ];
84
+ const store = createMockStore(entries);
85
+ const report = await runMemoryLint({ store });
86
+
87
+ expect(report.summary.duplicates).toBeGreaterThanOrEqual(1);
88
+ const finding = report.findings.find(f => f.check === "duplicate");
89
+ expect(finding).toBeDefined();
90
+ });
91
+
92
+ it("detects stale memories (old, never accessed)", async () => {
93
+ const oldTimestamp = Date.now() - 120 * 24 * 60 * 60 * 1000; // 120 days ago
94
+ const entries = [
95
+ makeEntry({
96
+ id: "s1",
97
+ text: "Some old fact",
98
+ timestamp: oldTimestamp,
99
+ metadata: JSON.stringify({
100
+ evolution: {
101
+ status: "active",
102
+ version: 1,
103
+ accessCount: 0,
104
+ lastAccessedAt: null,
105
+ supersededBy: null,
106
+ supersedes: null,
107
+ evolutionNote: null,
108
+ consolidatedInto: null,
109
+ contributedToPattern: null,
110
+ sourceMemories: [],
111
+ validFrom: oldTimestamp,
112
+ validUntil: null,
113
+ },
114
+ }),
115
+ }),
116
+ ];
117
+ const store = createMockStore(entries);
118
+ const report = await runMemoryLint({ store });
119
+
120
+ expect(report.summary.staleMemories).toBe(1);
121
+ const finding = report.findings.find(f => f.check === "stale");
122
+ expect(finding).toBeDefined();
123
+ expect(finding!.severity).toBe("info");
124
+ });
125
+
126
+ it("detects orphans with missing scope", async () => {
127
+ const entries = [
128
+ makeEntry({ id: "o1", text: "Orphan memory", scope: "" }),
129
+ makeEntry({ id: "o2", text: "Schema entry", scope: "__schema__" }),
130
+ ];
131
+ const store = createMockStore(entries);
132
+ const report = await runMemoryLint({ store });
133
+
134
+ expect(report.summary.orphans).toBe(2);
135
+ });
136
+
137
+ it("detects broken consolidation links", async () => {
138
+ const entries = [
139
+ makeEntry({
140
+ id: "b1",
141
+ text: "Points to deleted",
142
+ metadata: JSON.stringify({
143
+ evolution: {
144
+ status: "active",
145
+ version: 1,
146
+ accessCount: 5,
147
+ lastAccessedAt: Date.now(),
148
+ supersededBy: null,
149
+ supersedes: null,
150
+ evolutionNote: null,
151
+ consolidatedInto: "nonexistent-id-12345",
152
+ contributedToPattern: null,
153
+ sourceMemories: [],
154
+ validFrom: Date.now(),
155
+ validUntil: null,
156
+ },
157
+ }),
158
+ }),
159
+ ];
160
+ const store = createMockStore(entries);
161
+ const report = await runMemoryLint({ store });
162
+
163
+ expect(report.summary.orphans).toBe(1);
164
+ const finding = report.findings.find(f => f.check === "orphan" && f.severity === "warning");
165
+ expect(finding).toBeDefined();
166
+ });
167
+
168
+ it("skips non-active entries", async () => {
169
+ const entries = [
170
+ makeEntry({
171
+ id: "arc1",
172
+ text: "Archived memory",
173
+ scope: "",
174
+ metadata: JSON.stringify({
175
+ evolution: {
176
+ status: "archived",
177
+ version: 1,
178
+ accessCount: 0,
179
+ lastAccessedAt: null,
180
+ supersededBy: null,
181
+ supersedes: null,
182
+ evolutionNote: null,
183
+ consolidatedInto: null,
184
+ contributedToPattern: null,
185
+ sourceMemories: [],
186
+ validFrom: Date.now(),
187
+ validUntil: null,
188
+ },
189
+ }),
190
+ }),
191
+ ];
192
+ const store = createMockStore(entries);
193
+ const report = await runMemoryLint({ store });
194
+
195
+ // Archived entry should be skipped, so no orphan finding
196
+ expect(report.totalScanned).toBe(0);
197
+ expect(report.findings).toHaveLength(0);
198
+ });
199
+ });
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // computeHealthScore
203
+ // ---------------------------------------------------------------------------
204
+
205
+ describe("computeHealthScore", () => {
206
+ it("returns 100 when no issues", () => {
207
+ expect(computeHealthScore({ contradictions: 0, duplicates: 0, staleMemories: 0, orphans: 0 }, 100)).toBe(100);
208
+ });
209
+
210
+ it("deducts correctly for mixed findings", () => {
211
+ // 1 contradiction (-10) + 2 duplicates (-10) + 4 stale (-2) + 1 orphan (-3) = -25 → 75
212
+ expect(computeHealthScore({ contradictions: 1, duplicates: 2, staleMemories: 4, orphans: 1 }, 100)).toBe(75);
213
+ });
214
+
215
+ it("clamps to 0", () => {
216
+ expect(computeHealthScore({ contradictions: 20, duplicates: 0, staleMemories: 0, orphans: 0 }, 10)).toBe(0);
217
+ });
218
+ });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // formatMemoryLintReport
222
+ // ---------------------------------------------------------------------------
223
+
224
+ describe("formatMemoryLintReport", () => {
225
+ it("shows all-clear when no findings", () => {
226
+ const report: MemoryLintReport = {
227
+ findings: [],
228
+ healthScore: 100,
229
+ totalScanned: 50,
230
+ timestamp: "2026-04-08T12:00:00Z",
231
+ summary: { contradictions: 0, duplicates: 0, staleMemories: 0, orphans: 0 },
232
+ };
233
+ const output = formatMemoryLintReport(report);
234
+ expect(output).toContain("All Clear");
235
+ expect(output).toContain("100/100");
236
+ });
237
+
238
+ it("includes findings when present", () => {
239
+ const report: MemoryLintReport = {
240
+ findings: [
241
+ { check: "contradiction", severity: "warning", detail: '"A" vs "B"', memoryIds: ["id1", "id2"] },
242
+ ],
243
+ healthScore: 90,
244
+ totalScanned: 100,
245
+ timestamp: "2026-04-08T12:00:00Z",
246
+ summary: { contradictions: 1, duplicates: 0, staleMemories: 0, orphans: 0 },
247
+ };
248
+ const output = formatMemoryLintReport(report);
249
+ expect(output).toContain("Contradictions (1)");
250
+ expect(output).toContain("90/100");
251
+ });
252
+
253
+ it("summarizes stale when count > 5", () => {
254
+ const staleFindings = Array.from({ length: 10 }, (_, i) => ({
255
+ check: "stale" as const,
256
+ severity: "info" as const,
257
+ detail: `${100 + i}d old`,
258
+ memoryIds: [`stale${i}`],
259
+ }));
260
+ const report: MemoryLintReport = {
261
+ findings: staleFindings,
262
+ healthScore: 95,
263
+ totalScanned: 200,
264
+ timestamp: "2026-04-08T12:00:00Z",
265
+ summary: { contradictions: 0, duplicates: 0, staleMemories: 10, orphans: 0 },
266
+ };
267
+ const output = formatMemoryLintReport(report);
268
+ expect(output).toContain("Stale (10)");
269
+ expect(output).toContain("10 memories not accessed");
270
+ });
271
+ });