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,441 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { PersistMemoryDeps } from "./capture-engine.js";
3
+ import { persistMemory } from "./capture-engine.js";
4
+ import type { StoredMemoryRecord } from "./memory-schema.js";
5
+
6
+ export interface NormalizedMessage {
7
+ role: "user" | "assistant" | "system";
8
+ content: string;
9
+ timestamp?: string;
10
+ }
11
+
12
+ export type ConversationFormat =
13
+ | "claude-code"
14
+ | "claude-ai"
15
+ | "chatgpt"
16
+ | "slack"
17
+ | "plaintext";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Format detection
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export function detectFormat(content: string): ConversationFormat {
24
+ const trimmed = content.trimStart();
25
+
26
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
27
+ try {
28
+ const parsed = JSON.parse(trimmed);
29
+
30
+ if (Array.isArray(parsed)) {
31
+ if (parsed.length > 0 && parsed[0].chat_messages !== undefined) {
32
+ return "claude-ai";
33
+ }
34
+ if (parsed.length > 0 && parsed[0].mapping !== undefined) {
35
+ return "chatgpt";
36
+ }
37
+ if (parsed.length > 0 && parsed[0].ts !== undefined && parsed[0].text !== undefined) {
38
+ return "slack";
39
+ }
40
+ }
41
+
42
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
43
+ if (parsed.chat_conversations !== undefined) {
44
+ return "claude-ai";
45
+ }
46
+ if (parsed.mapping !== undefined) {
47
+ return "chatgpt";
48
+ }
49
+ if (parsed.messages !== undefined && Array.isArray(parsed.messages)) {
50
+ const first = parsed.messages[0];
51
+ if (first && first.ts !== undefined) {
52
+ return "slack";
53
+ }
54
+ }
55
+ }
56
+ } catch {
57
+ // Not valid JSON — fall through
58
+ }
59
+ }
60
+
61
+ // JSONL: each line is a JSON object with type field
62
+ const firstLine = trimmed.split("\n")[0]?.trim();
63
+ if (firstLine?.startsWith("{")) {
64
+ try {
65
+ const obj = JSON.parse(firstLine);
66
+ if (obj.type === "human" || obj.type === "assistant" || obj.message !== undefined) {
67
+ return "claude-code";
68
+ }
69
+ } catch {
70
+ // Not JSONL
71
+ }
72
+ }
73
+
74
+ return "plaintext";
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Claude Code JSONL normalizer
79
+ // ---------------------------------------------------------------------------
80
+
81
+ interface ClaudeCodeLine {
82
+ type?: string;
83
+ message?: {
84
+ content?: string | Array<{ type?: string; text?: string }>;
85
+ role?: string;
86
+ };
87
+ timestamp?: string;
88
+ }
89
+
90
+ export function normalizeClaudeCode(content: string): NormalizedMessage[] {
91
+ const messages: NormalizedMessage[] = [];
92
+
93
+ for (const line of content.split("\n")) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed) continue;
96
+
97
+ let parsed: ClaudeCodeLine;
98
+ try {
99
+ parsed = JSON.parse(trimmed);
100
+ } catch {
101
+ continue;
102
+ }
103
+
104
+ let role: NormalizedMessage["role"];
105
+ if (parsed.type === "human") {
106
+ role = "user";
107
+ } else if (parsed.type === "assistant") {
108
+ role = "assistant";
109
+ } else if (parsed.message?.role === "user" || parsed.type === "user") {
110
+ role = "user";
111
+ } else if (parsed.message?.role === "assistant") {
112
+ role = "assistant";
113
+ } else {
114
+ continue;
115
+ }
116
+
117
+ let text = "";
118
+ const rawContent = parsed.message?.content;
119
+ if (typeof rawContent === "string") {
120
+ text = rawContent;
121
+ } else if (Array.isArray(rawContent)) {
122
+ text = rawContent
123
+ .filter((part): part is { type?: string; text?: string } =>
124
+ typeof part === "object" && part !== null && typeof part.text === "string")
125
+ .map((part) => part.text!)
126
+ .join("\n");
127
+ }
128
+
129
+ if (!text.trim()) continue;
130
+
131
+ messages.push({
132
+ role,
133
+ content: text.trim(),
134
+ ...(parsed.timestamp ? { timestamp: parsed.timestamp } : {}),
135
+ });
136
+ }
137
+
138
+ return messages;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Claude.ai JSON export normalizer
143
+ // ---------------------------------------------------------------------------
144
+
145
+ interface ClaudeAiConversation {
146
+ chat_messages?: Array<{
147
+ sender?: string;
148
+ text?: string;
149
+ created_at?: string;
150
+ }>;
151
+ }
152
+
153
+ export function normalizeClaudeAi(content: string): NormalizedMessage[] {
154
+ const parsed = JSON.parse(content);
155
+ const messages: NormalizedMessage[] = [];
156
+
157
+ let conversations: ClaudeAiConversation[];
158
+ if (Array.isArray(parsed)) {
159
+ conversations = parsed;
160
+ } else if (parsed.chat_conversations) {
161
+ conversations = parsed.chat_conversations;
162
+ } else {
163
+ conversations = [parsed];
164
+ }
165
+
166
+ for (const conv of conversations) {
167
+ if (!conv.chat_messages) continue;
168
+ for (const msg of conv.chat_messages) {
169
+ let role: NormalizedMessage["role"];
170
+ if (msg.sender === "human") {
171
+ role = "user";
172
+ } else if (msg.sender === "assistant") {
173
+ role = "assistant";
174
+ } else {
175
+ continue;
176
+ }
177
+
178
+ const text = msg.text?.trim();
179
+ if (!text) continue;
180
+
181
+ messages.push({
182
+ role,
183
+ content: text,
184
+ ...(msg.created_at ? { timestamp: msg.created_at } : {}),
185
+ });
186
+ }
187
+ }
188
+
189
+ return messages;
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // ChatGPT conversations.json normalizer
194
+ // ---------------------------------------------------------------------------
195
+
196
+ interface ChatGPTMapping {
197
+ [id: string]: {
198
+ id: string;
199
+ parent?: string | null;
200
+ children?: string[];
201
+ message?: {
202
+ author?: { role?: string };
203
+ content?: { parts?: Array<string | Record<string, unknown>> };
204
+ create_time?: number;
205
+ } | null;
206
+ };
207
+ }
208
+
209
+ interface ChatGPTConversation {
210
+ mapping?: ChatGPTMapping;
211
+ }
212
+
213
+ export function normalizeChatGPT(content: string): NormalizedMessage[] {
214
+ const parsed = JSON.parse(content);
215
+ const messages: NormalizedMessage[] = [];
216
+
217
+ const conversations: ChatGPTConversation[] = Array.isArray(parsed) ? parsed : [parsed];
218
+
219
+ for (const conv of conversations) {
220
+ if (!conv.mapping) continue;
221
+ const mapping = conv.mapping;
222
+
223
+ // Find root: node whose parent is null or not in mapping
224
+ let rootId: string | null = null;
225
+ for (const [id, node] of Object.entries(mapping)) {
226
+ if (!node.parent || !mapping[node.parent]) {
227
+ rootId = id;
228
+ break;
229
+ }
230
+ }
231
+
232
+ if (!rootId) continue;
233
+
234
+ // BFS to rebuild order
235
+ const queue: string[] = [rootId];
236
+ const visited = new Set<string>();
237
+
238
+ while (queue.length > 0) {
239
+ const nodeId = queue.shift()!;
240
+ if (visited.has(nodeId)) continue;
241
+ visited.add(nodeId);
242
+
243
+ const node = mapping[nodeId];
244
+ if (!node) continue;
245
+
246
+ if (node.message?.author?.role && node.message.content?.parts) {
247
+ const authorRole = node.message.author.role;
248
+ let role: NormalizedMessage["role"] | null = null;
249
+ if (authorRole === "user") role = "user";
250
+ else if (authorRole === "assistant") role = "assistant";
251
+ else if (authorRole === "system") role = "system";
252
+
253
+ if (role) {
254
+ const text = node.message.content.parts
255
+ .filter((part): part is string => typeof part === "string")
256
+ .join("\n")
257
+ .trim();
258
+
259
+ if (text) {
260
+ const msg: NormalizedMessage = { role, content: text };
261
+ if (node.message.create_time) {
262
+ msg.timestamp = new Date(node.message.create_time * 1000).toISOString();
263
+ }
264
+ messages.push(msg);
265
+ }
266
+ }
267
+ }
268
+
269
+ if (node.children) {
270
+ for (const childId of node.children) {
271
+ queue.push(childId);
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ return messages;
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Slack JSON export normalizer
282
+ // ---------------------------------------------------------------------------
283
+
284
+ interface SlackMessage {
285
+ user?: string;
286
+ bot_id?: string;
287
+ text?: string;
288
+ ts?: string;
289
+ }
290
+
291
+ export function normalizeSlack(content: string): NormalizedMessage[] {
292
+ const parsed = JSON.parse(content);
293
+ const messages: NormalizedMessage[] = [];
294
+
295
+ let rawMessages: SlackMessage[];
296
+ if (Array.isArray(parsed)) {
297
+ rawMessages = parsed;
298
+ } else if (parsed.messages && Array.isArray(parsed.messages)) {
299
+ rawMessages = parsed.messages;
300
+ } else {
301
+ return messages;
302
+ }
303
+
304
+ for (const msg of rawMessages) {
305
+ const text = msg.text?.trim();
306
+ if (!text) continue;
307
+
308
+ const role: NormalizedMessage["role"] = msg.bot_id ? "assistant" : "user";
309
+
310
+ const result: NormalizedMessage = { role, content: text };
311
+ if (msg.ts) {
312
+ result.timestamp = new Date(parseFloat(msg.ts) * 1000).toISOString();
313
+ }
314
+ messages.push(result);
315
+ }
316
+
317
+ return messages;
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Plaintext normalizer
322
+ // ---------------------------------------------------------------------------
323
+
324
+ const ROLE_PREFIXES: Array<[RegExp, NormalizedMessage["role"]]> = [
325
+ [/^User:\s*/i, "user"],
326
+ [/^Human:\s*/i, "user"],
327
+ [/^Assistant:\s*/i, "assistant"],
328
+ [/^AI:\s*/i, "assistant"],
329
+ [/^System:\s*/i, "system"],
330
+ ];
331
+
332
+ export function normalizePlaintext(content: string): NormalizedMessage[] {
333
+ const messages: NormalizedMessage[] = [];
334
+ let currentRole: NormalizedMessage["role"] | null = null;
335
+ let currentLines: string[] = [];
336
+
337
+ function flush() {
338
+ if (currentRole && currentLines.length > 0) {
339
+ const text = currentLines.join("\n").trim();
340
+ if (text) {
341
+ messages.push({ role: currentRole, content: text });
342
+ }
343
+ }
344
+ currentLines = [];
345
+ }
346
+
347
+ for (const line of content.split("\n")) {
348
+ let matched = false;
349
+ for (const [pattern, role] of ROLE_PREFIXES) {
350
+ const match = line.match(pattern);
351
+ if (match) {
352
+ flush();
353
+ currentRole = role;
354
+ const remainder = line.slice(match[0].length).trim();
355
+ if (remainder) currentLines.push(remainder);
356
+ matched = true;
357
+ break;
358
+ }
359
+ }
360
+ if (!matched && currentRole) {
361
+ currentLines.push(line);
362
+ }
363
+ }
364
+ flush();
365
+
366
+ return messages;
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Unified normalize dispatcher
371
+ // ---------------------------------------------------------------------------
372
+
373
+ export function normalizeConversation(content: string, format: ConversationFormat): NormalizedMessage[] {
374
+ switch (format) {
375
+ case "claude-code":
376
+ return normalizeClaudeCode(content);
377
+ case "claude-ai":
378
+ return normalizeClaudeAi(content);
379
+ case "chatgpt":
380
+ return normalizeChatGPT(content);
381
+ case "slack":
382
+ return normalizeSlack(content);
383
+ case "plaintext":
384
+ return normalizePlaintext(content);
385
+ }
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Batch ingest: normalized messages -> persistMemory
390
+ // ---------------------------------------------------------------------------
391
+
392
+ export interface ImportResult {
393
+ total: number;
394
+ stored: number;
395
+ rejected: number;
396
+ errors: string[];
397
+ }
398
+
399
+ export async function importConversation(
400
+ deps: PersistMemoryDeps,
401
+ filePath: string,
402
+ scope: string,
403
+ format: ConversationFormat | "auto",
404
+ ): Promise<ImportResult> {
405
+ const content = readFileSync(filePath, "utf-8");
406
+ const resolvedFormat = format === "auto" ? detectFormat(content) : format;
407
+ const messages = normalizeConversation(content, resolvedFormat);
408
+
409
+ return ingestNormalizedMessages(deps, messages, scope);
410
+ }
411
+
412
+ export async function ingestNormalizedMessages(
413
+ deps: PersistMemoryDeps,
414
+ messages: NormalizedMessage[],
415
+ scope: string,
416
+ ): Promise<ImportResult> {
417
+ const result: ImportResult = { total: messages.length, stored: 0, rejected: 0, errors: [] };
418
+
419
+ for (const msg of messages) {
420
+ try {
421
+ const stored: StoredMemoryRecord = await persistMemory(deps, {
422
+ text: msg.content,
423
+ category: "events" as const,
424
+ importance: 0.7,
425
+ scope,
426
+ source: "conversation_import" as const,
427
+ tags: [`role:${msg.role}`, ...(msg.timestamp ? [`ts:${msg.timestamp}`] : [])],
428
+ });
429
+
430
+ if (stored.disposition === "rejected") {
431
+ result.rejected++;
432
+ } else {
433
+ result.stored++;
434
+ }
435
+ } catch (err) {
436
+ result.errors.push(err instanceof Error ? err.message : String(err));
437
+ }
438
+ }
439
+
440
+ return result;
441
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * LC-P4: Data Checkup Engine
3
+ *
4
+ * Health checks for the memory database, inspired by lossless-claw's
5
+ * IntegrityChecker. Pure read-only diagnostics — never modifies data.
6
+ *
7
+ * Checks:
8
+ * 1. Vector dimension consistency
9
+ * 2. Orphan memories (scope missing or empty)
10
+ * 3. Tier distribution health
11
+ * 4. Conflict backlog
12
+ * 5. Version group integrity
13
+ */
14
+
15
+ import type { MemoryEntry, MemoryStore } from "./store.js";
16
+ import { resolveTier, type MemoryTier } from "./decay-engine.js";
17
+ import { measureInterferenceDensity } from "./interference-detector.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export interface CheckResult {
24
+ name: string;
25
+ status: "ok" | "warning" | "error";
26
+ detail: string;
27
+ }
28
+
29
+ export interface CheckupReport {
30
+ checks: CheckResult[];
31
+ totalEntries: number;
32
+ timestamp: string;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Individual checks
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Check 1: All vectors should have the same dimension. */
40
+ function checkVectorDimensions(entries: MemoryEntry[]): CheckResult {
41
+ const dims = new Map<number, number>(); // dim → count
42
+ for (const e of entries) {
43
+ const d = e.vector?.length ?? 0;
44
+ dims.set(d, (dims.get(d) ?? 0) + 1);
45
+ }
46
+
47
+ if (dims.size <= 1) {
48
+ const dim = dims.keys().next().value ?? 0;
49
+ return { name: "vector_dimensions", status: "ok", detail: `All ${entries.length} entries have dimension ${dim}` };
50
+ }
51
+
52
+ const sorted = [...dims.entries()].sort((a, b) => b[1] - a[1]);
53
+ const expected = sorted[0][0];
54
+ const mismatched = sorted.slice(1).reduce((sum, [, c]) => sum + c, 0);
55
+ return {
56
+ name: "vector_dimensions",
57
+ status: "error",
58
+ detail: `${mismatched} entries have wrong dimension (expected ${expected}): ${sorted.map(([d, c]) => `dim=${d}: ${c}`).join(", ")}`,
59
+ };
60
+ }
61
+
62
+ /** Check 2: Memories with missing/empty scope. */
63
+ function checkOrphanMemories(entries: MemoryEntry[]): CheckResult {
64
+ const orphans = entries.filter(e => !e.scope || e.scope.trim() === "" || e.scope === "__schema__");
65
+ if (orphans.length === 0) {
66
+ return { name: "orphan_memories", status: "ok", detail: "No orphan memories found" };
67
+ }
68
+ return {
69
+ name: "orphan_memories",
70
+ status: orphans.length > 10 ? "error" : "warning",
71
+ detail: `${orphans.length} memories with missing/empty/schema scope (IDs: ${orphans.slice(0, 5).map(e => e.id.slice(0, 8)).join(", ")}${orphans.length > 5 ? "..." : ""})`,
72
+ };
73
+ }
74
+
75
+ /** Check 3: Tier distribution — core should not exceed 500, peripheral should not exceed 70%. */
76
+ function checkTierDistribution(entries: MemoryEntry[]): CheckResult {
77
+ const tierCounts: Record<MemoryTier, number> = { core: 0, working: 0, peripheral: 0 };
78
+ for (const e of entries) {
79
+ const tier = resolveTier(e.metadata);
80
+ tierCounts[tier]++;
81
+ }
82
+
83
+ const total = entries.length;
84
+ const issues: string[] = [];
85
+
86
+ if (tierCounts.core > 500) {
87
+ issues.push(`core tier has ${tierCounts.core} entries (max recommended: 500)`);
88
+ }
89
+ if (total >= 20 && tierCounts.peripheral / total > 0.7) {
90
+ const pct = Math.round(tierCounts.peripheral / total * 100);
91
+ issues.push(`peripheral tier is ${pct}% of total (recommended: ≤70%)`);
92
+ }
93
+
94
+ const dist = `core=${tierCounts.core}, working=${tierCounts.working}, peripheral=${tierCounts.peripheral}`;
95
+ if (issues.length === 0) {
96
+ return { name: "tier_distribution", status: "ok", detail: `Healthy distribution: ${dist}` };
97
+ }
98
+ return { name: "tier_distribution", status: "warning", detail: `${dist} — ${issues.join("; ")}` };
99
+ }
100
+
101
+ /** Check 4: Open conflict backlog. */
102
+ function checkConflictBacklog(openConflictCount: number): CheckResult {
103
+ if (openConflictCount === 0) {
104
+ return { name: "conflict_backlog", status: "ok", detail: "No open conflicts" };
105
+ }
106
+ const status = openConflictCount > 20 ? "error" : openConflictCount > 5 ? "warning" : "ok";
107
+ return { name: "conflict_backlog", status, detail: `${openConflictCount} open conflicts` };
108
+ }
109
+
110
+ /** Check 5: Version group integrity — members should reference the same group ID. */
111
+ function checkVersionGroups(entries: MemoryEntry[]): CheckResult {
112
+ const groups = new Map<string, { ids: string[]; ranks: number[] }>();
113
+
114
+ for (const e of entries) {
115
+ if (!e.metadata) continue;
116
+ try {
117
+ const meta = JSON.parse(e.metadata);
118
+ const group = meta.version_group;
119
+ if (typeof group !== "string") continue;
120
+ if (!groups.has(group)) groups.set(group, { ids: [], ranks: [] });
121
+ const g = groups.get(group)!;
122
+ g.ids.push(e.id);
123
+ g.ranks.push(typeof meta.version_rank === "number" ? meta.version_rank : -1);
124
+ } catch { /* skip */ }
125
+ }
126
+
127
+ if (groups.size === 0) {
128
+ return { name: "version_groups", status: "ok", detail: "No version groups found" };
129
+ }
130
+
131
+ const issues: string[] = [];
132
+ for (const [groupId, { ids, ranks }] of groups) {
133
+ // Single-member group is suspicious
134
+ if (ids.length < 2) {
135
+ issues.push(`group ${groupId.slice(0, 8)} has only ${ids.length} member`);
136
+ }
137
+ // Check for missing ranks
138
+ const missingRanks = ranks.filter(r => r < 0).length;
139
+ if (missingRanks > 0) {
140
+ issues.push(`group ${groupId.slice(0, 8)} has ${missingRanks} members with missing rank`);
141
+ }
142
+ }
143
+
144
+ if (issues.length === 0) {
145
+ return { name: "version_groups", status: "ok", detail: `${groups.size} version groups, all healthy` };
146
+ }
147
+ return {
148
+ name: "version_groups",
149
+ status: issues.length > 3 ? "error" : "warning",
150
+ detail: `${groups.size} groups, ${issues.length} issue(s): ${issues.slice(0, 3).join("; ")}${issues.length > 3 ? "..." : ""}`,
151
+ };
152
+ }
153
+
154
+ /** Check 6 (F2): Interference density — detect semantic clustering pressure. */
155
+ function checkInterferenceDensity(entries: MemoryEntry[]): CheckResult {
156
+ // Only check entries with vectors (skip schema/empty entries)
157
+ const withVectors = entries.filter(e => e.vector && e.vector.length > 0);
158
+ if (withVectors.length < 10) {
159
+ return { name: "interference_density", status: "ok", detail: "Too few entries for interference analysis" };
160
+ }
161
+
162
+ // Sample up to 200 entries for performance (interference detection is O(n²))
163
+ const sample = withVectors.length > 200
164
+ ? withVectors.sort(() => Math.random() - 0.5).slice(0, 200)
165
+ : withVectors;
166
+
167
+ const density = measureInterferenceDensity(sample);
168
+ const issues: string[] = [];
169
+
170
+ if (density.highRiskCount > sample.length * 0.2) {
171
+ issues.push(`${density.highRiskCount} high-risk entries (>${Math.round(sample.length * 0.2)} threshold)`);
172
+ }
173
+ if (density.avgClusterSize > 5) {
174
+ issues.push(`avg cluster size ${density.avgClusterSize.toFixed(1)} (recommended: ≤5)`);
175
+ }
176
+
177
+ const detail = `${density.clusterCount} clusters, avg size ${density.avgClusterSize.toFixed(1)}, ${density.highRiskCount} high-risk`;
178
+ if (issues.length === 0) {
179
+ return { name: "interference_density", status: "ok", detail: `Healthy: ${detail}` };
180
+ }
181
+ return {
182
+ name: "interference_density",
183
+ status: issues.length > 1 ? "error" : "warning",
184
+ detail: `${detail} — ${issues.join("; ")}`,
185
+ };
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Main
190
+ // ---------------------------------------------------------------------------
191
+
192
+ export interface CheckupDeps {
193
+ store: Pick<MemoryStore, "list" | "stats">;
194
+ openConflictCount: number;
195
+ }
196
+
197
+ export async function runDataCheckup(deps: CheckupDeps): Promise<CheckupReport> {
198
+ const entries = await deps.store.list(undefined, undefined, 10000, 0);
199
+
200
+ return {
201
+ checks: [
202
+ checkVectorDimensions(entries),
203
+ checkOrphanMemories(entries),
204
+ checkTierDistribution(entries),
205
+ checkConflictBacklog(deps.openConflictCount),
206
+ checkVersionGroups(entries),
207
+ checkInterferenceDensity(entries),
208
+ ],
209
+ totalEntries: entries.length,
210
+ timestamp: new Date().toISOString(),
211
+ };
212
+ }
213
+
214
+ export function formatCheckupReport(report: CheckupReport): string {
215
+ const lines = [
216
+ `Data Checkup Report (${report.timestamp})`,
217
+ `Total entries scanned: ${report.totalEntries}`,
218
+ "",
219
+ ];
220
+
221
+ for (const check of report.checks) {
222
+ const icon = check.status === "ok" ? "[OK]" : check.status === "warning" ? "[WARN]" : "[ERR]";
223
+ lines.push(`${icon} ${check.name}: ${check.detail}`);
224
+ }
225
+
226
+ const errorCount = report.checks.filter(c => c.status === "error").length;
227
+ const warnCount = report.checks.filter(c => c.status === "warning").length;
228
+ lines.push("");
229
+ lines.push(`Summary: ${errorCount} error(s), ${warnCount} warning(s), ${report.checks.length - errorCount - warnCount} ok`);
230
+
231
+ return lines.join("\n");
232
+ }