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,349 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { scanForPromotions, formatPromotionResult, type PromotionScanResult } from "../skill-promotion.js";
4
+ import type { MemoryEntry, MemorySearchResult } from "../store.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Mock Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ let idSeq = 0;
11
+
12
+ function makeEntry(overrides: Partial<MemoryEntry> = {}): MemoryEntry {
13
+ idSeq += 1;
14
+ return {
15
+ id: `entry-${String(idSeq).padStart(4, "0")}`,
16
+ text: `Case about deploying to production #${idSeq}`,
17
+ vector: [1, 0, 0],
18
+ category: "cases",
19
+ scope: "project:test",
20
+ importance: 0.7,
21
+ timestamp: 1_700_000_000_000 + idSeq,
22
+ metadata: JSON.stringify({
23
+ evolution: {
24
+ status: "active",
25
+ version: 1,
26
+ accessCount: 0,
27
+ lastAccessedAt: null,
28
+ supersededBy: null,
29
+ consolidatedInto: null,
30
+ sourceMemories: [],
31
+ validFrom: Date.now(),
32
+ validUntil: null,
33
+ },
34
+ }),
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ /** Create a vector that is similar to the base [1,0,0] with controlled similarity. */
40
+ function similarVector(sim: number): number[] {
41
+ // For cosine similarity, we want cos(theta) = sim
42
+ // Use 2D vector [sim, sqrt(1-sim^2), 0] dot [1, 0, 0] = sim
43
+ const complement = Math.sqrt(1 - sim * sim);
44
+ return [sim, complement, 0];
45
+ }
46
+
47
+ function createMockStore(entries: MemoryEntry[]) {
48
+ return {
49
+ async list(
50
+ _scopeFilter?: string[],
51
+ _category?: string,
52
+ _limit?: number,
53
+ _offset?: number,
54
+ ): Promise<MemoryEntry[]> {
55
+ return entries;
56
+ },
57
+ async vectorSearch(
58
+ vector: number[],
59
+ limit = 5,
60
+ minScore = 0.3,
61
+ _scopeFilter?: string[],
62
+ ): Promise<MemorySearchResult[]> {
63
+ // Simple mock: return entries sorted by cosine similarity to vector
64
+ const scored = entries
65
+ .filter(e => e.vector?.length > 0)
66
+ .map(e => {
67
+ let dot = 0, normA = 0, normB = 0;
68
+ for (let i = 0; i < vector.length; i++) {
69
+ dot += vector[i] * (e.vector[i] ?? 0);
70
+ normA += vector[i] * vector[i];
71
+ normB += (e.vector[i] ?? 0) * (e.vector[i] ?? 0);
72
+ }
73
+ const score = (normA > 0 && normB > 0)
74
+ ? dot / (Math.sqrt(normA) * Math.sqrt(normB))
75
+ : 0;
76
+ return { entry: e, score };
77
+ })
78
+ .filter(r => r.score >= minScore)
79
+ .sort((a, b) => b.score - a.score)
80
+ .slice(0, limit);
81
+ return scored;
82
+ },
83
+ };
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Tests
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe("scanForPromotions", () => {
91
+ it("returns 0 candidates for empty scope", async () => {
92
+ const store = createMockStore([]);
93
+ const result = await scanForPromotions(store, "project:empty");
94
+
95
+ expect(result.candidates).toHaveLength(0);
96
+ expect(result.scannedCases).toBe(0);
97
+ expect(result.scannedPatterns).toBe(0);
98
+ });
99
+
100
+ it("returns 0 candidates when fewer than minCaseOccurrences similar cases", async () => {
101
+ // Two cases with the same vector — below default threshold of 3
102
+ const entries = [
103
+ makeEntry({ vector: [1, 0, 0] }),
104
+ makeEntry({ vector: [0.99, 0.14, 0] }),
105
+ ];
106
+ const store = createMockStore(entries);
107
+ const result = await scanForPromotions(store, "project:test");
108
+
109
+ expect(result.candidates).toHaveLength(0);
110
+ expect(result.scannedCases).toBe(2);
111
+ });
112
+
113
+ it("produces case_to_pattern candidate when 3+ similar cases exist", async () => {
114
+ const entries = [
115
+ makeEntry({ text: "Deploy error: missing env var", vector: [1, 0, 0] }),
116
+ makeEntry({ text: "Deploy failure: env variable not set", vector: similarVector(0.95) }),
117
+ makeEntry({ text: "Deploy issue: environment config missing", vector: similarVector(0.90) }),
118
+ ];
119
+ const store = createMockStore(entries);
120
+ const result = await scanForPromotions(store, "project:test", {
121
+ minCaseOccurrences: 3,
122
+ caseSimilarityThreshold: 0.75,
123
+ });
124
+
125
+ expect(result.candidates.length).toBeGreaterThanOrEqual(1);
126
+ const caseCandidate = result.candidates.find(c => c.type === "case_to_pattern");
127
+ expect(caseCandidate).toBeDefined();
128
+ expect(caseCandidate!.sourceEntries.length).toBeGreaterThanOrEqual(3);
129
+ expect(caseCandidate!.suggestedName).toBeTruthy();
130
+ expect(caseCandidate!.suggestedDescription).toContain("cases");
131
+ });
132
+
133
+ it("does not produce candidates from dissimilar cases", async () => {
134
+ // 3 cases with very different vectors
135
+ const entries = [
136
+ makeEntry({ text: "Case A", vector: [1, 0, 0] }),
137
+ makeEntry({ text: "Case B", vector: [0, 1, 0] }),
138
+ makeEntry({ text: "Case C", vector: [0, 0, 1] }),
139
+ ];
140
+ const store = createMockStore(entries);
141
+ const result = await scanForPromotions(store, "project:test", {
142
+ minCaseOccurrences: 3,
143
+ caseSimilarityThreshold: 0.75,
144
+ });
145
+
146
+ expect(result.candidates).toHaveLength(0);
147
+ });
148
+
149
+ it("computes confidence using Bayesian smoothing", async () => {
150
+ // 4 similar cases: confidence = 4 / (4 + 2) = 0.667
151
+ const entries = [
152
+ makeEntry({ text: "Deploy error A", vector: [1, 0, 0] }),
153
+ makeEntry({ text: "Deploy error B", vector: similarVector(0.95) }),
154
+ makeEntry({ text: "Deploy error C", vector: similarVector(0.92) }),
155
+ makeEntry({ text: "Deploy error D", vector: similarVector(0.88) }),
156
+ ];
157
+ const store = createMockStore(entries);
158
+ const result = await scanForPromotions(store, "project:test", {
159
+ minCaseOccurrences: 3,
160
+ caseSimilarityThreshold: 0.75,
161
+ });
162
+
163
+ expect(result.candidates.length).toBeGreaterThanOrEqual(1);
164
+ const c = result.candidates[0];
165
+ // cluster of 4: confidence = 4/(4+2) = 0.6667
166
+ expect(c.confidence).toBeCloseTo(4 / 6, 2);
167
+ });
168
+
169
+ it("produces pattern_to_skill when pattern has steps and related cases", async () => {
170
+ const patternVec: number[] = [0.9, 0.43, 0]; // cos sim with [1,0,0] ~ 0.9
171
+ const entries = [
172
+ makeEntry({
173
+ category: "patterns",
174
+ text: "Production Deploy Pattern\n\nSteps:\n1. Check environment\n2. Run preflight\n3. Deploy containers",
175
+ vector: patternVec,
176
+ }),
177
+ makeEntry({ text: "Deploy case: ran preflight then deployed", vector: [1, 0, 0] }),
178
+ makeEntry({ text: "Deploy fix: environment check before push", vector: similarVector(0.88) }),
179
+ ];
180
+ const store = createMockStore(entries);
181
+ const result = await scanForPromotions(store, "project:test", {
182
+ caseSimilarityThreshold: 0.75,
183
+ });
184
+
185
+ const skillCandidate = result.candidates.find(c => c.type === "pattern_to_skill");
186
+ expect(skillCandidate).toBeDefined();
187
+ expect(skillCandidate!.suggestedImplementation).toContain("Check environment");
188
+ expect(skillCandidate!.sourceEntries.length).toBeGreaterThanOrEqual(3); // pattern + 2 cases
189
+ });
190
+
191
+ it("does not produce pattern_to_skill when pattern lacks structured steps", async () => {
192
+ const patternVec: number[] = [0.9, 0.43, 0];
193
+ const entries = [
194
+ makeEntry({
195
+ category: "patterns",
196
+ text: "Just a general note about deploying without structured steps",
197
+ vector: patternVec,
198
+ }),
199
+ makeEntry({ text: "Deploy case one", vector: [1, 0, 0] }),
200
+ makeEntry({ text: "Deploy case two", vector: similarVector(0.88) }),
201
+ ];
202
+ const store = createMockStore(entries);
203
+ const result = await scanForPromotions(store, "project:test", {
204
+ caseSimilarityThreshold: 0.75,
205
+ });
206
+
207
+ const skillCandidate = result.candidates.find(c => c.type === "pattern_to_skill");
208
+ expect(skillCandidate).toBeUndefined();
209
+ });
210
+
211
+ it("respects maxCandidates limit", async () => {
212
+ // Create two distinct clusters of 3, but set maxCandidates=1
213
+ const entries = [
214
+ // Cluster 1
215
+ makeEntry({ text: "Deploy A", vector: [1, 0, 0] }),
216
+ makeEntry({ text: "Deploy B", vector: similarVector(0.95) }),
217
+ makeEntry({ text: "Deploy C", vector: similarVector(0.90) }),
218
+ // Cluster 2
219
+ makeEntry({ text: "Auth fix A", vector: [0, 1, 0] }),
220
+ makeEntry({ text: "Auth fix B", vector: [0.1, 0.99, 0] }),
221
+ makeEntry({ text: "Auth fix C", vector: [0.15, 0.98, 0] }),
222
+ ];
223
+ const store = createMockStore(entries);
224
+ const result = await scanForPromotions(store, "project:test", {
225
+ minCaseOccurrences: 3,
226
+ caseSimilarityThreshold: 0.75,
227
+ maxCandidates: 1,
228
+ });
229
+
230
+ expect(result.candidates).toHaveLength(1);
231
+ });
232
+
233
+ it("filters out archived entries", async () => {
234
+ const archivedMeta = JSON.stringify({
235
+ evolution: {
236
+ status: "archived",
237
+ version: 1,
238
+ accessCount: 0,
239
+ lastAccessedAt: null,
240
+ supersededBy: null,
241
+ consolidatedInto: null,
242
+ sourceMemories: [],
243
+ validFrom: Date.now(),
244
+ validUntil: null,
245
+ },
246
+ });
247
+ const entries = [
248
+ makeEntry({ text: "Active case", vector: [1, 0, 0] }),
249
+ makeEntry({ text: "Archived case", vector: similarVector(0.95), metadata: archivedMeta }),
250
+ makeEntry({ text: "Another archived", vector: similarVector(0.90), metadata: archivedMeta }),
251
+ ];
252
+ const store = createMockStore(entries);
253
+ const result = await scanForPromotions(store, "project:test", {
254
+ minCaseOccurrences: 3,
255
+ });
256
+
257
+ // Only 1 active case, so no candidates
258
+ expect(result.candidates).toHaveLength(0);
259
+ expect(result.scannedCases).toBe(1);
260
+ });
261
+
262
+ it("skips entries without vectors", async () => {
263
+ const entries = [
264
+ makeEntry({ text: "No vector case A", vector: [] }),
265
+ makeEntry({ text: "No vector case B", vector: [] }),
266
+ makeEntry({ text: "No vector case C", vector: [] }),
267
+ ];
268
+ const store = createMockStore(entries);
269
+ const result = await scanForPromotions(store, "project:test");
270
+
271
+ expect(result.candidates).toHaveLength(0);
272
+ });
273
+
274
+ it("candidates are sorted by confidence descending", async () => {
275
+ // Cluster 1: 5 similar cases -> confidence 5/7
276
+ // Cluster 2: 3 similar cases -> confidence 3/5
277
+ const entries = [
278
+ // Large cluster
279
+ makeEntry({ text: "Deploy err 1", vector: [1, 0, 0] }),
280
+ makeEntry({ text: "Deploy err 2", vector: similarVector(0.95) }),
281
+ makeEntry({ text: "Deploy err 3", vector: similarVector(0.93) }),
282
+ makeEntry({ text: "Deploy err 4", vector: similarVector(0.91) }),
283
+ makeEntry({ text: "Deploy err 5", vector: similarVector(0.89) }),
284
+ // Small cluster (orthogonal direction)
285
+ makeEntry({ text: "Auth bug 1", vector: [0, 1, 0] }),
286
+ makeEntry({ text: "Auth bug 2", vector: [0.1, 0.99, 0] }),
287
+ makeEntry({ text: "Auth bug 3", vector: [0.15, 0.98, 0] }),
288
+ ];
289
+ const store = createMockStore(entries);
290
+ const result = await scanForPromotions(store, "project:test", {
291
+ minCaseOccurrences: 3,
292
+ caseSimilarityThreshold: 0.75,
293
+ });
294
+
295
+ if (result.candidates.length >= 2) {
296
+ expect(result.candidates[0].confidence).toBeGreaterThanOrEqual(result.candidates[1].confidence);
297
+ }
298
+ });
299
+ });
300
+
301
+ describe("formatPromotionResult", () => {
302
+ it("formats empty results", () => {
303
+ const result: PromotionScanResult = {
304
+ candidates: [],
305
+ scannedCases: 5,
306
+ scannedPatterns: 2,
307
+ };
308
+ const text = formatPromotionResult(result);
309
+ expect(text).toContain("5 cases");
310
+ expect(text).toContain("2 patterns");
311
+ expect(text).toContain("No promotion candidates found");
312
+ });
313
+
314
+ it("formats candidates with type and confidence", () => {
315
+ const result: PromotionScanResult = {
316
+ candidates: [{
317
+ type: "case_to_pattern",
318
+ sourceEntries: [{ id: "a", text: "test", score: 0.9 }],
319
+ suggestedName: "Test Pattern",
320
+ suggestedDescription: "A test",
321
+ confidence: 0.6,
322
+ }],
323
+ scannedCases: 3,
324
+ scannedPatterns: 1,
325
+ };
326
+ const text = formatPromotionResult(result);
327
+ expect(text).toContain("case_to_pattern");
328
+ expect(text).toContain("Test Pattern");
329
+ expect(text).toContain("60.0%");
330
+ });
331
+
332
+ it("includes implementation in pattern_to_skill output", () => {
333
+ const result: PromotionScanResult = {
334
+ candidates: [{
335
+ type: "pattern_to_skill",
336
+ sourceEntries: [{ id: "a", text: "test", score: 1.0 }],
337
+ suggestedName: "Deploy Skill",
338
+ suggestedDescription: "Deploy automation",
339
+ suggestedImplementation: "1. Check env\n2. Deploy",
340
+ confidence: 0.5,
341
+ }],
342
+ scannedCases: 2,
343
+ scannedPatterns: 1,
344
+ };
345
+ const text = formatPromotionResult(result);
346
+ expect(text).toContain("1. Check env");
347
+ expect(text).toContain("Implementation:");
348
+ });
349
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ buildSupersedeMetadata,
4
+ buildSupersedeMetadataForNew,
5
+ parseEvolution,
6
+ traceEvolution,
7
+ patchEvolution,
8
+ defaultEvolution,
9
+ } from "../memory-evolution.js";
10
+
11
+ describe("HP-1: Superseded chain enhancement", () => {
12
+ describe("bidirectional linking", () => {
13
+ it("buildSupersedeMetadata marks old → supersededBy", () => {
14
+ const oldMeta = patchEvolution("{}", defaultEvolution());
15
+ const result = buildSupersedeMetadata(oldMeta, "new-id");
16
+ const evo = parseEvolution(result);
17
+ expect(evo.status).toBe("superseded");
18
+ expect(evo.supersededBy).toBe("new-id");
19
+ expect(evo.validUntil).toBeGreaterThan(0);
20
+ });
21
+
22
+ it("buildSupersedeMetadataForNew marks new → supersedes + note", () => {
23
+ const newMeta = patchEvolution("{}", defaultEvolution());
24
+ const result = buildSupersedeMetadataForNew(newMeta, "old-id", "updated preference");
25
+ const evo = parseEvolution(result);
26
+ expect(evo.status).toBe("active"); // new memory stays active
27
+ expect(evo.supersedes).toBe("old-id");
28
+ expect(evo.evolutionNote).toBe("updated preference");
29
+ });
30
+
31
+ it("bidirectional: old.supersededBy == new.id AND new.supersedes == old.id", () => {
32
+ const oldMeta = patchEvolution("{}", defaultEvolution());
33
+ const newMeta = patchEvolution("{}", defaultEvolution());
34
+
35
+ const updatedOld = buildSupersedeMetadata(oldMeta, "new-id");
36
+ const updatedNew = buildSupersedeMetadataForNew(newMeta, "old-id");
37
+
38
+ const oldEvo = parseEvolution(updatedOld);
39
+ const newEvo = parseEvolution(updatedNew);
40
+
41
+ expect(oldEvo.supersededBy).toBe("new-id");
42
+ expect(newEvo.supersedes).toBe("old-id");
43
+ });
44
+ });
45
+
46
+ describe("traceEvolution", () => {
47
+ it("traces a 3-node chain", async () => {
48
+ // v1 → v2 → v3
49
+ const entries: Record<string, { metadata: string; timestamp: number }> = {
50
+ v1: {
51
+ metadata: patchEvolution("{}", {
52
+ status: "superseded",
53
+ supersededBy: "v2",
54
+ validFrom: 1000,
55
+ validUntil: 2000,
56
+ }),
57
+ timestamp: 1000,
58
+ },
59
+ v2: {
60
+ metadata: patchEvolution("{}", {
61
+ status: "superseded",
62
+ supersedes: "v1",
63
+ supersededBy: "v3",
64
+ evolutionNote: "added detail",
65
+ validFrom: 2000,
66
+ validUntil: 3000,
67
+ }),
68
+ timestamp: 2000,
69
+ },
70
+ v3: {
71
+ metadata: patchEvolution("{}", {
72
+ status: "active",
73
+ supersedes: "v2",
74
+ evolutionNote: "corrected error",
75
+ validFrom: 3000,
76
+ }),
77
+ timestamp: 3000,
78
+ },
79
+ };
80
+
81
+ const getEntry = async (id: string) => entries[id] ?? null;
82
+
83
+ // Trace from v2 (middle)
84
+ const trace = await traceEvolution("v2", getEntry);
85
+ expect(trace.length).toBe(3);
86
+ expect(trace[0].id).toBe("v1");
87
+ expect(trace[0].direction).toBe("predecessor");
88
+ expect(trace[1].id).toBe("v2");
89
+ expect(trace[1].direction).toBe("self");
90
+ expect(trace[2].id).toBe("v3");
91
+ expect(trace[2].direction).toBe("successor");
92
+ });
93
+
94
+ it("handles single node (no chain)", async () => {
95
+ const getEntry = async (id: string) => {
96
+ if (id === "solo") return { metadata: patchEvolution("{}", defaultEvolution()), timestamp: 1000 };
97
+ return null;
98
+ };
99
+
100
+ const trace = await traceEvolution("solo", getEntry);
101
+ expect(trace.length).toBe(1);
102
+ expect(trace[0].direction).toBe("self");
103
+ });
104
+
105
+ it("respects maxDepth", async () => {
106
+ // Create a long chain: v0 → v1 → v2 → ... → v20
107
+ const entries: Record<string, { metadata: string; timestamp: number }> = {};
108
+ for (let i = 0; i <= 20; i++) {
109
+ entries[`v${i}`] = {
110
+ metadata: patchEvolution("{}", {
111
+ status: i < 20 ? "superseded" : "active",
112
+ supersedes: i > 0 ? `v${i - 1}` : null,
113
+ supersededBy: i < 20 ? `v${i + 1}` : null,
114
+ validFrom: i * 1000,
115
+ }),
116
+ timestamp: i * 1000,
117
+ };
118
+ }
119
+
120
+ const getEntry = async (id: string) => entries[id] ?? null;
121
+ const trace = await traceEvolution("v10", getEntry, 3);
122
+ // maxDepth=3: at most 3 predecessors + self + 3 successors = 7
123
+ expect(trace.length).toBeLessThanOrEqual(7);
124
+ });
125
+
126
+ it("handles missing entries gracefully", async () => {
127
+ const entries: Record<string, { metadata: string; timestamp: number }> = {
128
+ v1: {
129
+ metadata: patchEvolution("{}", {
130
+ status: "active",
131
+ supersedes: "missing",
132
+ validFrom: 1000,
133
+ }),
134
+ timestamp: 1000,
135
+ },
136
+ };
137
+ const getEntry = async (id: string) => entries[id] ?? null;
138
+ const trace = await traceEvolution("v1", getEntry);
139
+ expect(trace.length).toBe(1); // only self, missing predecessor stops chain
140
+ });
141
+ });
142
+
143
+ describe("backward compatibility", () => {
144
+ it("parseEvolution defaults new fields to null", () => {
145
+ // Old metadata without supersedes/evolutionNote
146
+ const oldMeta = JSON.stringify({
147
+ evolution: { status: "active", version: 1, accessCount: 0 },
148
+ });
149
+ const evo = parseEvolution(oldMeta);
150
+ expect(evo.supersedes).toBeNull();
151
+ expect(evo.evolutionNote).toBeNull();
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * F3: Temporal Validity Windows — tests for eventTime, validUntil,
3
+ * retrieval filtering, and auto-GC decay acceleration.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ defaultEvolution,
9
+ parseEvolution,
10
+ patchEvolution,
11
+ type EvolutionMetadata,
12
+ } from "../memory-evolution.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Schema: eventTime field
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe("F3: eventTime in EvolutionMetadata", () => {
19
+ test("defaultEvolution includes eventTime: null", () => {
20
+ const evo = defaultEvolution(1000);
21
+ expect(evo.eventTime).toBeNull();
22
+ expect(evo.validFrom).toBe(1000);
23
+ expect(evo.validUntil).toBeNull();
24
+ });
25
+
26
+ test("parseEvolution extracts eventTime from metadata", () => {
27
+ const metadata = JSON.stringify({
28
+ evolution: {
29
+ status: "active",
30
+ version: 1,
31
+ accessCount: 0,
32
+ lastAccessedAt: null,
33
+ supersededBy: null,
34
+ supersedes: null,
35
+ evolutionNote: null,
36
+ consolidatedInto: null,
37
+ contributedToPattern: null,
38
+ sourceMemories: [],
39
+ validFrom: 1000,
40
+ validUntil: null,
41
+ eventTime: 5000,
42
+ },
43
+ });
44
+ const evo = parseEvolution(metadata);
45
+ expect(evo.eventTime).toBe(5000);
46
+ });
47
+
48
+ test("parseEvolution defaults eventTime to null when missing", () => {
49
+ const metadata = JSON.stringify({
50
+ evolution: {
51
+ status: "active",
52
+ version: 1,
53
+ validFrom: 1000,
54
+ },
55
+ });
56
+ const evo = parseEvolution(metadata);
57
+ expect(evo.eventTime).toBeNull();
58
+ });
59
+
60
+ test("patchEvolution can set eventTime", () => {
61
+ const metadata = JSON.stringify({ evolution: defaultEvolution(1000) });
62
+ const patched = patchEvolution(metadata, { eventTime: 9999 });
63
+ const evo = parseEvolution(patched);
64
+ expect(evo.eventTime).toBe(9999);
65
+ });
66
+
67
+ test("patchEvolution can set validUntil", () => {
68
+ const metadata = JSON.stringify({ evolution: defaultEvolution(1000) });
69
+ const patched = patchEvolution(metadata, { validUntil: 2000 });
70
+ const evo = parseEvolution(patched);
71
+ expect(evo.validUntil).toBe(2000);
72
+ // Other fields unchanged
73
+ expect(evo.validFrom).toBe(1000);
74
+ expect(evo.status).toBe("active");
75
+ });
76
+ });
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Retrieval: filterByValidity (unit-level via import)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ // We test the filterByValidity logic indirectly through the exported
83
+ // parseEvolution + the contract: expired memories get score * 0.2.
84
+ // Direct integration tested via full retriever in separate test files.
85
+
86
+ describe("F3: validity window logic", () => {
87
+ function makeEvo(overrides: Partial<EvolutionMetadata> = {}): EvolutionMetadata {
88
+ return { ...defaultEvolution(1000), ...overrides };
89
+ }
90
+
91
+ test("memory with validUntil in the future is not expired", () => {
92
+ const now = 5000;
93
+ const evo = makeEvo({ validUntil: 10000 });
94
+ // validUntil > now → not expired
95
+ expect(evo.validUntil).toBeGreaterThan(now);
96
+ });
97
+
98
+ test("memory with validUntil in the past is expired", () => {
99
+ const now = 5000;
100
+ const evo = makeEvo({ validUntil: 3000 });
101
+ // validUntil < now → expired
102
+ expect(evo.validUntil).toBeLessThan(now);
103
+ });
104
+
105
+ test("memory without validUntil never expires", () => {
106
+ const evo = makeEvo({ validUntil: null });
107
+ expect(evo.validUntil).toBeNull();
108
+ });
109
+
110
+ test("point-in-time query: memory valid in range", () => {
111
+ const evo = makeEvo({ validFrom: 1000, validUntil: 5000 });
112
+ const queryTime = 3000;
113
+ expect(evo.validFrom).toBeLessThanOrEqual(queryTime);
114
+ expect(evo.validUntil).toBeGreaterThanOrEqual(queryTime);
115
+ });
116
+
117
+ test("point-in-time query: memory not yet valid", () => {
118
+ const evo = makeEvo({ validFrom: 5000, validUntil: 10000 });
119
+ const queryTime = 3000;
120
+ expect(evo.validFrom).toBeGreaterThan(queryTime);
121
+ });
122
+
123
+ test("point-in-time query: memory already expired at query time", () => {
124
+ const evo = makeEvo({ validFrom: 1000, validUntil: 3000 });
125
+ const queryTime = 5000;
126
+ expect(evo.validUntil).toBeLessThan(queryTime);
127
+ });
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Auto-GC: decay acceleration for expired memories
132
+ // ---------------------------------------------------------------------------
133
+
134
+ describe("F3: GC decay acceleration", () => {
135
+ test("expired memory decay score is halved", () => {
136
+ // Simulate the GC logic: if validUntil < now, decayScore *= 0.5
137
+ const baseDecayScore = 0.30;
138
+ const now = 10000;
139
+ const validUntil = 5000; // expired
140
+ const adjusted = validUntil < now ? baseDecayScore * 0.5 : baseDecayScore;
141
+ expect(adjusted).toBe(0.15);
142
+ });
143
+
144
+ test("non-expired memory decay score unchanged", () => {
145
+ const baseDecayScore = 0.30;
146
+ const now = 10000;
147
+ const validUntil = 20000; // still valid
148
+ const adjusted = validUntil < now ? baseDecayScore * 0.5 : baseDecayScore;
149
+ expect(adjusted).toBe(0.30);
150
+ });
151
+
152
+ test("memory without validUntil — decay score unchanged", () => {
153
+ const baseDecayScore = 0.30;
154
+ const validUntil = null;
155
+ const adjusted = (validUntil != null && validUntil < Date.now())
156
+ ? baseDecayScore * 0.5
157
+ : baseDecayScore;
158
+ expect(adjusted).toBe(0.30);
159
+ });
160
+ });