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.
- package/.dockerignore +11 -0
- package/CLAUDE.md +7 -3
- package/Dockerfile +20 -0
- package/README.md +202 -130
- package/README_CN.md +263 -190
- package/ROADMAP.md +128 -81
- package/SKILL.md +250 -0
- package/assets/dashboard.png +0 -0
- package/assets/knowledge-graph.png +0 -0
- package/assets/screenshots/ui-full.png +0 -0
- package/assets/screenshots/ui-overview.png +0 -0
- package/assets/screenshots/ui-search.png +0 -0
- package/assets/ui/app.js +227 -3
- package/assets/ui/index.html +81 -11
- package/assets/ui/styles.css +178 -2
- package/bin/recallnest.mjs +62 -0
- package/bun.lock +337 -0
- package/daily-recall.sh +23 -0
- package/daily-reflections/2026-04-02.md +35 -0
- package/daily-reflections/2026-04-03.md +37 -0
- package/daily-reflections/2026-04-04.md +37 -0
- package/daily-reflections/2026-04-05.md +35 -0
- package/daily-reflections/2026-04-06.md +36 -0
- package/daily-reflections/2026-04-07.md +35 -0
- package/daily-reflections/2026-04-08.md +35 -0
- package/daily-reflections/2026-04-12.md +35 -0
- package/data/alias-map.example.json +22 -0
- package/docs/plans/2026-04-01-recall-boost.md +100 -0
- package/docs/superpowers/plans/2026-04-11-constructive-retrieval-emotion.md +1195 -0
- package/docs/superpowers/specs/2026-04-11-constructive-retrieval-emotion-design.md +398 -0
- package/glama.json +6 -0
- package/integrations/setup-all.sh +31 -0
- package/package.json +10 -3
- package/plan-philosophy-upgrades.md +363 -0
- package/plan-product-upgrades.md +155 -0
- package/plan.md +140 -0
- package/research-memory-evolution.md +440 -0
- package/research-rl-agent-memory.md +330 -0
- package/scripts/backfill-anchors.ts +130 -0
- package/scripts/diagnose-vector-search.ts +132 -0
- package/scripts/migrate-language.ts +75 -0
- package/scripts/verify-emotion-decay.ts +148 -0
- package/src/__tests__/activity-counter.test.ts +96 -0
- package/src/__tests__/admission-control.test.ts +206 -0
- package/src/__tests__/anchor-generator.test.ts +67 -0
- package/src/__tests__/babel-integration.test.ts +139 -0
- package/src/__tests__/capture-engine.test.ts +5 -3
- package/src/__tests__/cascade-forget.test.ts +1 -1
- package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
- package/src/__tests__/circuit-breaker.test.ts +99 -0
- package/src/__tests__/cluster-consolidation.test.ts +712 -0
- package/src/__tests__/confidence-metadata.test.ts +125 -0
- package/src/__tests__/confidence-tracker.test.ts +53 -0
- package/src/__tests__/consolidation-engine.test.ts +1 -1
- package/src/__tests__/constructive-retrieval.test.ts +509 -0
- package/src/__tests__/context-collapse-renderer.test.ts +214 -0
- package/src/__tests__/context-reconstructor.test.ts +65 -0
- package/src/__tests__/conversation-importer.test.ts +692 -0
- package/src/__tests__/data-checkup.test.ts +225 -0
- package/src/__tests__/decay-exemption.test.ts +84 -0
- package/src/__tests__/dedup-guard.test.ts +39 -0
- package/src/__tests__/distill-lock.test.ts +176 -0
- package/src/__tests__/dream-pipeline.test.ts +232 -0
- package/src/__tests__/emotion-decay.test.ts +46 -0
- package/src/__tests__/emotion-detector.test.ts +56 -0
- package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
- package/src/__tests__/emotion-retrieval.test.ts +56 -0
- package/src/__tests__/ethics-layer.test.ts +476 -0
- package/src/__tests__/feature-flags-integration.test.ts +54 -0
- package/src/__tests__/frequency-tracker.test.ts +113 -0
- package/src/__tests__/graph-export.test.ts +489 -0
- package/src/__tests__/interference-detector.test.ts +203 -0
- package/src/__tests__/mcp-efficiency.test.ts +288 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +243 -0
- package/src/__tests__/memory-evolution.test.ts +271 -0
- package/src/__tests__/memory-lint.test.ts +271 -0
- package/src/__tests__/narrative-architecture.test.ts +456 -0
- package/src/__tests__/pending-review.test.ts +77 -0
- package/src/__tests__/post-retrieval-filter.test.ts +178 -0
- package/src/__tests__/prediction-engine.test.ts +349 -0
- package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
- package/src/__tests__/preference-slots.test.ts +99 -0
- package/src/__tests__/query-expander-alias.test.ts +89 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/resume-context.test.ts +156 -1
- package/src/__tests__/salience-scoring.test.ts +106 -0
- package/src/__tests__/session-distiller.test.ts +611 -0
- package/src/__tests__/skill-engine.test.ts +546 -0
- package/src/__tests__/skill-promotion.test.ts +349 -0
- package/src/__tests__/supersede-chain.test.ts +154 -0
- package/src/__tests__/temporal-validity.test.ts +160 -0
- package/src/__tests__/topic-tag.test.ts +113 -0
- package/src/__tests__/trusted-memory.test.ts +828 -0
- package/src/__tests__/write-verifier.test.ts +122 -0
- package/src/access-tracker.ts +3 -2
- package/src/activity-counter.ts +109 -0
- package/src/adaptive-retrieval.ts +6 -5
- package/src/admission-control.ts +163 -0
- package/src/anchor-generator.ts +80 -0
- package/src/api-server.ts +10 -0
- package/src/audit-log.ts +122 -0
- package/src/auto-gc.ts +98 -29
- package/src/auto-recall.ts +33 -4
- package/src/capture-engine.ts +372 -23
- package/src/cascade-forget.ts +3 -2
- package/src/cli.ts +107 -5
- package/src/compat.ts +22 -0
- package/src/confidence-tracker.ts +210 -47
- package/src/conflict-store.ts +12 -3
- package/src/consolidation-engine.ts +296 -8
- package/src/context-collapse-renderer.ts +222 -0
- package/src/context-composer-stable-selection.ts +5 -1
- package/src/context-composer.ts +239 -4
- package/src/context-reconstructor.ts +404 -0
- package/src/conversation-importer.ts +441 -0
- package/src/data-checkup.ts +232 -0
- package/src/decay-engine.ts +84 -0
- package/src/distill-lock.ts +122 -0
- package/src/doctor.ts +8 -10
- package/src/dream-pipeline.ts +227 -0
- package/src/emotion-detector.ts +90 -0
- package/src/eval.ts +3 -2
- package/src/forget-engine.ts +269 -0
- package/src/frequency-tracker.ts +175 -0
- package/src/graph-export.ts +644 -0
- package/src/ingest.ts +36 -6
- package/src/interference-detector.ts +187 -0
- package/src/kg-extractor.ts +15 -24
- package/src/kg-store.ts +2 -1
- package/src/language-hook.ts +67 -0
- package/src/llm-client.ts +289 -6
- package/src/mcp-server.ts +707 -80
- package/src/memory-assets.ts +22 -5
- package/src/memory-evolution.ts +365 -0
- package/src/memory-lint.ts +429 -0
- package/src/memory-output.ts +69 -0
- package/src/memory-schema.ts +74 -2
- package/src/narrative-schema.ts +79 -0
- package/src/narrative-tagger.ts +164 -0
- package/src/pii-detector.ts +98 -0
- package/src/post-retrieval-filter.ts +76 -0
- package/src/prediction-engine.ts +253 -0
- package/src/preference-slots.ts +65 -2
- package/src/prospective-memory.ts +215 -0
- package/src/query-expander.ts +67 -0
- package/src/recall-governor.ts +183 -0
- package/src/retention-policy.ts +90 -0
- package/src/retriever.ts +828 -55
- package/src/rif.ts +34 -8
- package/src/runtime-config.ts +23 -8
- package/src/scope-inventory.ts +4 -3
- package/src/scope-policy.ts +4 -0
- package/src/session-distiller.ts +307 -0
- package/src/session-output.ts +40 -0
- package/src/session-schema.ts +37 -0
- package/src/session-store.ts +7 -2
- package/src/skill-engine.ts +208 -0
- package/src/skill-promotion.ts +293 -0
- package/src/skill-schema.ts +39 -0
- package/src/store.ts +96 -10
- package/src/topic-tag.ts +66 -0
- package/src/tracker.ts +3 -1
- package/src/ui-server.ts +80 -8
- package/src/workflow-observation-scope-review.ts +2 -1
- package/src/workflow-observation-store.ts +2 -1
- 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
|
+
});
|