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,476 @@
1
+ /**
2
+ * Tests for Phase 2: Memory Ethics Layer
3
+ *
4
+ * Covers:
5
+ * - PrivacyTier schema + parsePrivacyTier
6
+ * - Forget engine: single + scope-level forget
7
+ * - Cascade-forget lifecycle unification
8
+ * - Audit log: forget + cascade_forget operations
9
+ * - Privacy tier gate in capture (KG extraction)
10
+ * - StoreMemoryInput accepts privacyTier
11
+ */
12
+ import { describe, expect, test } from "bun:test";
13
+ import {
14
+ PrivacyTierSchema,
15
+ parsePrivacyTier,
16
+ PRIVACY_TIERS,
17
+ StoreMemoryInputSchema,
18
+ type PrivacyTier,
19
+ } from "../memory-schema.js";
20
+ import { isActiveMemory, patchEvolution, parseEvolution } from "../memory-evolution.js";
21
+ import { createAuditLogger, type AuditLogger } from "../audit-log.js";
22
+ import { forgetMemory, forgetByScope, type ForgetByIdDeps } from "../forget-engine.js";
23
+ import { cascadeForget, DEFAULT_CASCADE_FORGET_CONFIG } from "../cascade-forget.js";
24
+ import type { MemoryEntry, MemoryStore } from "../store.js";
25
+ import { existsSync, mkdirSync, rmSync, readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function makeEntry(overrides: Partial<MemoryEntry> = {}): MemoryEntry {
33
+ return {
34
+ id: overrides.id || "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
35
+ text: overrides.text || "test memory entry",
36
+ vector: overrides.vector || Array(768).fill(0.1),
37
+ category: overrides.category || "events",
38
+ scope: overrides.scope || "project:test",
39
+ importance: overrides.importance ?? 0.7,
40
+ timestamp: overrides.timestamp || Date.now(),
41
+ metadata: overrides.metadata || "{}",
42
+ };
43
+ }
44
+
45
+ function makeMockStore(entries: MemoryEntry[]): MemoryStore {
46
+ const data = new Map(entries.map(e => [e.id, { ...e }]));
47
+ return {
48
+ get: async (id: string) => data.get(id) || null,
49
+ delete: async (id: string) => {
50
+ const existed = data.has(id);
51
+ data.delete(id);
52
+ return existed;
53
+ },
54
+ update: async (id: string, updates: Partial<MemoryEntry>) => {
55
+ const entry = data.get(id);
56
+ if (!entry) return null;
57
+ const updated = { ...entry, ...updates };
58
+ data.set(id, updated);
59
+ return updated;
60
+ },
61
+ list: async (scopeFilter?: string[]) => {
62
+ const all = [...data.values()];
63
+ if (!scopeFilter) return all;
64
+ return all.filter(e => scopeFilter.includes(e.scope));
65
+ },
66
+ vectorSearch: async (vector: number[], limit: number, threshold: number, scopes?: string[]) => {
67
+ const all = [...data.values()];
68
+ const filtered = scopes
69
+ ? all.filter(e => scopes.includes(e.scope))
70
+ : all;
71
+ // Fake similarity: entries with same scope get high score
72
+ return filtered.slice(0, limit).map(e => ({
73
+ entry: e,
74
+ score: 0.85,
75
+ }));
76
+ },
77
+ store: async (entry: any) => {
78
+ data.set(entry.id, entry);
79
+ return entry;
80
+ },
81
+ } as any;
82
+ }
83
+
84
+ function makeMockKGStore() {
85
+ const deletedSources: string[] = [];
86
+ const deletedScopes: string[] = [];
87
+ return {
88
+ deleteBySource: async (sourceMemoryId: string) => {
89
+ deletedSources.push(sourceMemoryId);
90
+ },
91
+ deleteByScope: async (scope: string) => {
92
+ deletedScopes.push(scope);
93
+ },
94
+ _deletedSources: deletedSources,
95
+ _deletedScopes: deletedScopes,
96
+ };
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // 1. PrivacyTier Schema
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe("PrivacyTier Schema", () => {
104
+ test("all four tiers are valid", () => {
105
+ for (const tier of PRIVACY_TIERS) {
106
+ expect(PrivacyTierSchema.parse(tier)).toBe(tier);
107
+ }
108
+ });
109
+
110
+ test("invalid tier throws", () => {
111
+ expect(() => PrivacyTierSchema.parse("secret")).toThrow();
112
+ expect(() => PrivacyTierSchema.parse("")).toThrow();
113
+ });
114
+
115
+ test("parsePrivacyTier returns durable for absent metadata", () => {
116
+ expect(parsePrivacyTier(undefined)).toBe("durable");
117
+ expect(parsePrivacyTier("")).toBe("durable");
118
+ });
119
+
120
+ test("parsePrivacyTier returns durable for metadata without privacyTier", () => {
121
+ expect(parsePrivacyTier(JSON.stringify({ source: "manual" }))).toBe("durable");
122
+ });
123
+
124
+ test("parsePrivacyTier extracts tier from metadata", () => {
125
+ expect(parsePrivacyTier(JSON.stringify({ privacyTier: "ephemeral" }))).toBe("ephemeral");
126
+ expect(parsePrivacyTier(JSON.stringify({ privacyTier: "private" }))).toBe("private");
127
+ expect(parsePrivacyTier(JSON.stringify({ privacyTier: "shared" }))).toBe("shared");
128
+ });
129
+
130
+ test("parsePrivacyTier returns durable for malformed metadata", () => {
131
+ expect(parsePrivacyTier("not json")).toBe("durable");
132
+ expect(parsePrivacyTier(JSON.stringify({ privacyTier: "invalid" }))).toBe("durable");
133
+ });
134
+ });
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // 2. StoreMemoryInput accepts privacyTier
138
+ // ---------------------------------------------------------------------------
139
+
140
+ describe("StoreMemoryInput with privacyTier", () => {
141
+ test("default privacyTier is durable", () => {
142
+ const input = StoreMemoryInputSchema.parse({
143
+ text: "test memory",
144
+ scope: "project:test",
145
+ });
146
+ expect(input.privacyTier).toBe("durable");
147
+ });
148
+
149
+ test("explicit privacyTier is preserved", () => {
150
+ const input = StoreMemoryInputSchema.parse({
151
+ text: "test memory",
152
+ scope: "project:test",
153
+ privacyTier: "ephemeral",
154
+ });
155
+ expect(input.privacyTier).toBe("ephemeral");
156
+ });
157
+
158
+ test("invalid privacyTier throws", () => {
159
+ expect(() => StoreMemoryInputSchema.parse({
160
+ text: "test memory",
161
+ scope: "project:test",
162
+ privacyTier: "top_secret",
163
+ })).toThrow();
164
+ });
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // 3. Lifecycle Unification (cascade-forget uses isActiveMemory)
169
+ // ---------------------------------------------------------------------------
170
+
171
+ describe("Lifecycle Unification", () => {
172
+ test("cascadeForget skips archived entries via evolution.status", async () => {
173
+ const archivedMeta = patchEvolution("{}", { status: "archived" });
174
+ const primary = makeEntry({ id: "primary-id-0000-0000-000000000001" });
175
+ const archivedEntry = makeEntry({
176
+ id: "archived-entry-0000-0000-000000000002",
177
+ metadata: archivedMeta,
178
+ });
179
+ const activeEntry = makeEntry({
180
+ id: "active-entry-0000-0000-0000000000003",
181
+ metadata: patchEvolution("{}", { status: "active" }),
182
+ importance: 0.8,
183
+ });
184
+
185
+ const store = makeMockStore([primary, archivedEntry, activeEntry]);
186
+ const result = await cascadeForget(
187
+ store,
188
+ { id: primary.id, vector: primary.vector, scope: primary.scope },
189
+ DEFAULT_CASCADE_FORGET_CONFIG,
190
+ );
191
+
192
+ // Archived entry should NOT be demoted
193
+ expect(result.demotedIds).not.toContain(archivedEntry.id);
194
+ });
195
+
196
+ test("cascadeForget skips superseded entries via evolution.status", async () => {
197
+ const supersededMeta = patchEvolution("{}", { status: "superseded" });
198
+ const primary = makeEntry({ id: "primary-id-0000-0000-000000000010" });
199
+ const supersededEntry = makeEntry({
200
+ id: "superseded-0000-0000-0000000000020",
201
+ metadata: supersededMeta,
202
+ });
203
+
204
+ const store = makeMockStore([primary, supersededEntry]);
205
+ const result = await cascadeForget(
206
+ store,
207
+ { id: primary.id, vector: primary.vector, scope: primary.scope },
208
+ DEFAULT_CASCADE_FORGET_CONFIG,
209
+ );
210
+
211
+ expect(result.demotedIds).not.toContain(supersededEntry.id);
212
+ });
213
+
214
+ test("isActiveMemory correctly identifies active vs non-active", () => {
215
+ expect(isActiveMemory(undefined)).toBe(true); // no metadata = active
216
+ expect(isActiveMemory(patchEvolution("{}", { status: "active" }))).toBe(true);
217
+ expect(isActiveMemory(patchEvolution("{}", { status: "pending_review" }))).toBe(true);
218
+ expect(isActiveMemory(patchEvolution("{}", { status: "archived" }))).toBe(false);
219
+ expect(isActiveMemory(patchEvolution("{}", { status: "superseded" }))).toBe(false);
220
+ });
221
+ });
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // 4. Audit Log Extensions
225
+ // ---------------------------------------------------------------------------
226
+
227
+ describe("Audit Log Extensions", () => {
228
+ const tmpDir = join(import.meta.dir, ".tmp-audit-test");
229
+
230
+ test("audit logger accepts forget operation", () => {
231
+ const logPath = join(tmpDir, "audit-forget-test.jsonl");
232
+ try { mkdirSync(tmpDir, { recursive: true }); } catch { /* ok */ }
233
+ const logger = createAuditLogger(logPath);
234
+
235
+ logger.log({
236
+ operation: "forget",
237
+ scope: "project:test",
238
+ memoryId: "test-id",
239
+ actor: "system",
240
+ details: "forget test",
241
+ });
242
+
243
+ const content = readFileSync(logPath, "utf-8");
244
+ const parsed = JSON.parse(content.trim());
245
+ expect(parsed.operation).toBe("forget");
246
+ expect(parsed.scope).toBe("project:test");
247
+
248
+ try { rmSync(tmpDir, { recursive: true }); } catch { /* cleanup */ }
249
+ });
250
+
251
+ test("audit logger accepts cascade_forget operation", () => {
252
+ const logPath = join(tmpDir, "audit-cascade-test.jsonl");
253
+ try { mkdirSync(tmpDir, { recursive: true }); } catch { /* ok */ }
254
+ const logger = createAuditLogger(logPath);
255
+
256
+ logger.log({
257
+ operation: "cascade_forget",
258
+ scope: "project:test",
259
+ memoryId: "test-id",
260
+ actor: "system",
261
+ details: "cascade forget test",
262
+ });
263
+
264
+ const content = readFileSync(logPath, "utf-8");
265
+ const parsed = JSON.parse(content.trim());
266
+ expect(parsed.operation).toBe("cascade_forget");
267
+
268
+ try { rmSync(tmpDir, { recursive: true }); } catch { /* cleanup */ }
269
+ });
270
+ });
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // 5. Forget Engine
274
+ // ---------------------------------------------------------------------------
275
+
276
+ describe("Forget Engine", () => {
277
+ test("forgetMemory succeeds for ephemeral memory (no confirm needed)", async () => {
278
+ const meta = JSON.stringify({ privacyTier: "ephemeral" });
279
+ const entry = makeEntry({ metadata: meta });
280
+ const store = makeMockStore([entry]);
281
+ const kgStore = makeMockKGStore();
282
+
283
+ const result = await forgetMemory(
284
+ { store, kgStore: kgStore as any },
285
+ { memoryId: entry.id, confirm: false },
286
+ );
287
+
288
+ expect(result.success).toBe(true);
289
+ expect(result.kgTriplesRemoved).toBe(true);
290
+ expect(kgStore._deletedSources).toContain(entry.id);
291
+ });
292
+
293
+ test("forgetMemory requires confirm for durable tier", async () => {
294
+ const entry = makeEntry(); // default = durable
295
+ const store = makeMockStore([entry]);
296
+
297
+ const result = await forgetMemory(
298
+ { store },
299
+ { memoryId: entry.id, confirm: false },
300
+ );
301
+
302
+ expect(result.success).toBe(false);
303
+ expect(result.error).toContain("durable");
304
+ expect(result.error).toContain("confirm=true");
305
+ });
306
+
307
+ test("forgetMemory succeeds for durable with confirm=true", async () => {
308
+ const entry = makeEntry();
309
+ const store = makeMockStore([entry]);
310
+
311
+ const result = await forgetMemory(
312
+ { store },
313
+ { memoryId: entry.id, confirm: true, reason: "user requested" },
314
+ );
315
+
316
+ expect(result.success).toBe(true);
317
+ expect(result.evidence).toBeDefined();
318
+ expect(result.evidence!.reason).toBe("user requested");
319
+ expect(result.evidence!.privacyTier).toBe("durable");
320
+ });
321
+
322
+ test("forgetMemory returns error for nonexistent ID", async () => {
323
+ const store = makeMockStore([]);
324
+
325
+ const result = await forgetMemory(
326
+ { store },
327
+ { memoryId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", confirm: true },
328
+ );
329
+
330
+ expect(result.success).toBe(false);
331
+ expect(result.error).toContain("not found");
332
+ });
333
+
334
+ test("forgetMemory preserves evidence snapshot", async () => {
335
+ const entry = makeEntry({
336
+ text: "important secret",
337
+ importance: 0.9,
338
+ });
339
+ const store = makeMockStore([entry]);
340
+
341
+ const result = await forgetMemory(
342
+ { store },
343
+ { memoryId: entry.id, confirm: true },
344
+ );
345
+
346
+ expect(result.success).toBe(true);
347
+ expect(result.evidence).toBeDefined();
348
+ expect(result.evidence!.entry.text).toBe("important secret");
349
+ expect(result.evidence!.entry.importance).toBe(0.9);
350
+ expect(result.evidence!.forgottenAt).toBeTruthy();
351
+ });
352
+
353
+ test("forgetMemory cascades to related memories", async () => {
354
+ const primary = makeEntry({ id: "primary-id-0000-0000-000000000001", importance: 0.8 });
355
+ const related = makeEntry({
356
+ id: "related-id-0000-0000-000000000002",
357
+ importance: 0.8,
358
+ metadata: patchEvolution("{}", { status: "active" }),
359
+ });
360
+
361
+ const store = makeMockStore([primary, related]);
362
+ const result = await forgetMemory(
363
+ { store },
364
+ { memoryId: primary.id, confirm: true },
365
+ );
366
+
367
+ expect(result.success).toBe(true);
368
+ // Cascade should have attempted to demote
369
+ // (exact count depends on mock similarity scores)
370
+ expect(result.cascadeResult).toBeDefined();
371
+ });
372
+
373
+ test("forgetMemory logs audit trail", async () => {
374
+ const tmpDir = join(import.meta.dir, ".tmp-forget-audit");
375
+ const logPath = join(tmpDir, "forget-audit.jsonl");
376
+ try { mkdirSync(tmpDir, { recursive: true }); } catch { /* ok */ }
377
+
378
+ const entry = makeEntry({ metadata: JSON.stringify({ privacyTier: "private" }) });
379
+ const store = makeMockStore([entry]);
380
+ const auditLogger = createAuditLogger(logPath);
381
+
382
+ const result = await forgetMemory(
383
+ { store, auditLogger },
384
+ { memoryId: entry.id, confirm: true, reason: "privacy cleanup" },
385
+ );
386
+
387
+ expect(result.success).toBe(true);
388
+
389
+ const content = readFileSync(logPath, "utf-8").trim();
390
+ const lines = content.split("\n").map(l => JSON.parse(l));
391
+ const forgetLog = lines.find(l => l.operation === "forget");
392
+ expect(forgetLog).toBeDefined();
393
+ expect(forgetLog.details).toContain("private");
394
+ expect(forgetLog.details).toContain("privacy cleanup");
395
+
396
+ try { rmSync(tmpDir, { recursive: true }); } catch { /* cleanup */ }
397
+ });
398
+
399
+ test("forgetMemory cleans up KG triples", async () => {
400
+ const entry = makeEntry();
401
+ const store = makeMockStore([entry]);
402
+ const kgStore = makeMockKGStore();
403
+
404
+ const result = await forgetMemory(
405
+ { store, kgStore: kgStore as any },
406
+ { memoryId: entry.id, confirm: true },
407
+ );
408
+
409
+ expect(result.success).toBe(true);
410
+ expect(result.kgTriplesRemoved).toBe(true);
411
+ expect(kgStore._deletedSources).toContain(entry.id);
412
+ });
413
+
414
+ test("forgetByScope deletes all entries in scope", async () => {
415
+ const entries = [
416
+ makeEntry({ id: "scope-entry-0000-0000-000000000001", scope: "project:cleanup", metadata: JSON.stringify({ privacyTier: "private" }) }),
417
+ makeEntry({ id: "scope-entry-0000-0000-000000000002", scope: "project:cleanup", metadata: JSON.stringify({ privacyTier: "ephemeral" }) }),
418
+ makeEntry({ id: "other-scope-0000-0000-000000000003", scope: "project:keep" }),
419
+ ];
420
+ const store = makeMockStore(entries);
421
+ const kgStore = makeMockKGStore();
422
+
423
+ const result = await forgetByScope(
424
+ { store, kgStore: kgStore as any },
425
+ "project:cleanup",
426
+ true,
427
+ "scope cleanup",
428
+ );
429
+
430
+ expect(result.forgottenCount).toBe(2);
431
+ expect(result.kgScopeCleared).toBe(true);
432
+ expect(kgStore._deletedScopes).toContain("project:cleanup");
433
+ });
434
+
435
+ test("forgetByScope requires confirm", async () => {
436
+ const entries = [
437
+ makeEntry({ id: "scope-entry-0000-0000-000000000010", scope: "project:cleanup" }),
438
+ ];
439
+ const store = makeMockStore(entries);
440
+
441
+ const result = await forgetByScope(
442
+ { store },
443
+ "project:cleanup",
444
+ false,
445
+ );
446
+
447
+ expect(result.forgottenCount).toBe(0);
448
+ });
449
+ });
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // 6. Privacy Tier values
453
+ // ---------------------------------------------------------------------------
454
+
455
+ describe("Privacy Tier Semantics", () => {
456
+ test("PRIVACY_TIERS has exactly 4 tiers", () => {
457
+ expect(PRIVACY_TIERS).toHaveLength(4);
458
+ expect(PRIVACY_TIERS).toContain("ephemeral");
459
+ expect(PRIVACY_TIERS).toContain("private");
460
+ expect(PRIVACY_TIERS).toContain("durable");
461
+ expect(PRIVACY_TIERS).toContain("shared");
462
+ });
463
+
464
+ test("ephemeral and private should block KG (semantics check)", () => {
465
+ // This tests the logic that would be used in capture-engine
466
+ const kgBlockedTiers: PrivacyTier[] = ["ephemeral", "private"];
467
+ const kgAllowedTiers: PrivacyTier[] = ["durable", "shared"];
468
+
469
+ for (const tier of kgBlockedTiers) {
470
+ expect(tier === "ephemeral" || tier === "private").toBe(true);
471
+ }
472
+ for (const tier of kgAllowedTiers) {
473
+ expect(tier !== "ephemeral" && tier !== "private").toBe(true);
474
+ }
475
+ });
476
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+ import { detectEmotionIfEnabled } from "../emotion-detector.js";
3
+ import { shouldReconstruct, extractCitedIds, removeSentencesWithId } from "../context-reconstructor.js";
4
+ import { adjustHalfLifeForEmotion, weibullDecay } from "../decay-engine.js";
5
+
6
+ describe("feature flag isolation", () => {
7
+ const saved = {
8
+ emo: process.env.RECALLNEST_EMOTION_SCORING,
9
+ con: process.env.RECALLNEST_CONSTRUCTIVE_RETRIEVAL,
10
+ };
11
+
12
+ afterAll(() => {
13
+ if (saved.emo !== undefined) process.env.RECALLNEST_EMOTION_SCORING = saved.emo;
14
+ else delete process.env.RECALLNEST_EMOTION_SCORING;
15
+ if (saved.con !== undefined) process.env.RECALLNEST_CONSTRUCTIVE_RETRIEVAL = saved.con;
16
+ else delete process.env.RECALLNEST_CONSTRUCTIVE_RETRIEVAL;
17
+ });
18
+
19
+ test("emotion off -> detectEmotionIfEnabled returns null", () => {
20
+ delete process.env.RECALLNEST_EMOTION_SCORING;
21
+ expect(detectEmotionIfEnabled("terrible bug")).toBeNull();
22
+ });
23
+
24
+ test("constructive off -> shouldReconstruct false", () => {
25
+ expect(shouldReconstruct({
26
+ flagEnabled: false, callerOptIn: true, resultCount: 10, llmAvailable: true,
27
+ })).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe("emotion + decay end-to-end", () => {
32
+ test("emotional memory retains more score at 30 days", () => {
33
+ const baseHL = 60;
34
+ const emoHL = adjustHalfLifeForEmotion(baseHL, { valence: -0.8, arousal: 0.7, label: "frustration" });
35
+ expect(weibullDecay(30, emoHL, "working")).toBeGreaterThan(weibullDecay(30, baseHL, "working"));
36
+ });
37
+ });
38
+
39
+ describe("grounding end-to-end", () => {
40
+ test("removes phantom citations and lowers confidence", () => {
41
+ const text = "Valid [src:r1]. Fake [src:phantom]. Also valid [src:r2].";
42
+ const validIds = new Set(["r1", "r2"]);
43
+ let result = text;
44
+ let confidence = 1.0;
45
+ for (const id of extractCitedIds(result)) {
46
+ if (!validIds.has(id)) {
47
+ result = removeSentencesWithId(result, id);
48
+ confidence -= 0.2;
49
+ }
50
+ }
51
+ expect(result).not.toContain("phantom");
52
+ expect(confidence).toBe(0.8);
53
+ });
54
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { FrequencyTracker } from "../frequency-tracker.js";
3
+ import { existsSync, unlinkSync, mkdirSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ const TEST_DIR = join(import.meta.dir, "../../.test-data");
7
+ const TEST_FILE = join(TEST_DIR, "freq-test.json");
8
+
9
+ function cleanup() {
10
+ try { if (existsSync(TEST_FILE)) unlinkSync(TEST_FILE); } catch {}
11
+ }
12
+
13
+ describe("FrequencyTracker", () => {
14
+ beforeEach(() => {
15
+ if (!existsSync(TEST_DIR)) mkdirSync(TEST_DIR, { recursive: true });
16
+ cleanup();
17
+ });
18
+ afterEach(cleanup);
19
+
20
+ it("records hits and increments count", () => {
21
+ const tracker = new FrequencyTracker({ filePath: TEST_FILE });
22
+ tracker.recordHits(["mem-1", "mem-2"]);
23
+ tracker.recordHits(["mem-1"]);
24
+
25
+ expect(tracker.getStats("mem-1")?.hitCount).toBe(2);
26
+ expect(tracker.getStats("mem-2")?.hitCount).toBe(1);
27
+ expect(tracker.getStats("mem-3")).toBeUndefined();
28
+ tracker.dispose();
29
+ });
30
+
31
+ it("returns 1.0 boost below minHitsForBoost", () => {
32
+ const tracker = new FrequencyTracker({ filePath: TEST_FILE, minHitsForBoost: 3 });
33
+ tracker.recordHits(["mem-1"]);
34
+ tracker.recordHits(["mem-1"]);
35
+ expect(tracker.getBoostMultiplier("mem-1")).toBe(1.0);
36
+ tracker.dispose();
37
+ });
38
+
39
+ it("returns >1.0 boost at or above minHitsForBoost", () => {
40
+ const tracker = new FrequencyTracker({ filePath: TEST_FILE, minHitsForBoost: 2 });
41
+ tracker.recordHits(["mem-1"]);
42
+ tracker.recordHits(["mem-1"]);
43
+ tracker.recordHits(["mem-1"]);
44
+
45
+ const boost = tracker.getBoostMultiplier("mem-1");
46
+ expect(boost).toBeGreaterThan(1.0);
47
+ // log2(3) * 0.15 = ~0.238 → boost ≈ 1.238
48
+ expect(boost).toBeLessThan(1.5);
49
+ tracker.dispose();
50
+ });
51
+
52
+ it("shouldPromoteToCore respects threshold", () => {
53
+ const tracker = new FrequencyTracker({
54
+ filePath: TEST_FILE,
55
+ corePromotionThreshold: 3,
56
+ });
57
+ tracker.recordHits(["mem-1"]);
58
+ tracker.recordHits(["mem-1"]);
59
+ expect(tracker.shouldPromoteToCore("mem-1")).toBe(false);
60
+
61
+ tracker.recordHits(["mem-1"]);
62
+ expect(tracker.shouldPromoteToCore("mem-1")).toBe(true);
63
+ tracker.dispose();
64
+ });
65
+
66
+ it("persists and loads from file", () => {
67
+ const tracker1 = new FrequencyTracker({ filePath: TEST_FILE });
68
+ tracker1.recordHits(["mem-a"]);
69
+ tracker1.recordHits(["mem-a"]);
70
+ tracker1.recordHits(["mem-a"]);
71
+ tracker1.flush();
72
+ tracker1.dispose();
73
+
74
+ const tracker2 = new FrequencyTracker({ filePath: TEST_FILE });
75
+ expect(tracker2.getStats("mem-a")?.hitCount).toBe(3);
76
+ tracker2.dispose();
77
+ });
78
+
79
+ it("tracks size correctly", () => {
80
+ const tracker = new FrequencyTracker({ filePath: TEST_FILE });
81
+ expect(tracker.size).toBe(0);
82
+ tracker.recordHits(["a", "b", "c"]);
83
+ expect(tracker.size).toBe(3);
84
+ tracker.dispose();
85
+ });
86
+
87
+ it("applies time decay to effective hit count", () => {
88
+ const tracker = new FrequencyTracker({
89
+ filePath: TEST_FILE,
90
+ decayHalfLifeDays: 30,
91
+ minHitsForBoost: 1,
92
+ });
93
+ // Simulate old hits by manipulating stats directly
94
+ tracker.recordHits(["old-mem"]);
95
+ tracker.recordHits(["old-mem"]);
96
+ tracker.recordHits(["old-mem"]);
97
+ tracker.recordHits(["old-mem"]);
98
+ tracker.recordHits(["old-mem"]);
99
+
100
+ // Fresh entry should have full boost
101
+ const freshBoost = tracker.getBoostMultiplier("old-mem");
102
+ expect(freshBoost).toBeGreaterThan(1.0);
103
+
104
+ // Manually set lastHitAt to 60 days ago
105
+ const stats = tracker.getStats("old-mem")!;
106
+ stats.lastHitAt = Date.now() - 60 * 86_400_000;
107
+
108
+ // Decayed boost should be lower
109
+ const decayedBoost = tracker.getBoostMultiplier("old-mem");
110
+ expect(decayedBoost).toBeLessThan(freshBoost);
111
+ tracker.dispose();
112
+ });
113
+ });