recallnest 1.4.1 → 2.2.1

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 (233) hide show
  1. package/.env +4 -0
  2. package/CLAUDE.md +7 -3
  3. package/README.md +202 -130
  4. package/README_CN.md +263 -190
  5. package/bin/recallnest.mjs +62 -0
  6. package/config.json +55 -0
  7. package/daily-recall.sh +23 -0
  8. package/integrations/setup-all.sh +31 -0
  9. package/logs/daily-recall.error.log +0 -0
  10. package/logs/daily-recall.log +30 -0
  11. package/logs/ingest-2026-04-11.log +110 -0
  12. package/logs/ingest-2026-04-12.log +90 -0
  13. package/logs/launchagent-stderr.log +0 -0
  14. package/logs/launchagent-stdout.log +0 -0
  15. package/package.json +10 -3
  16. package/src/access-tracker.ts +3 -2
  17. package/src/activity-counter.ts +109 -0
  18. package/src/adaptive-retrieval.ts +6 -5
  19. package/src/admission-control.ts +163 -0
  20. package/src/anchor-generator.ts +80 -0
  21. package/src/api-server.ts +10 -0
  22. package/src/audit-log.ts +122 -0
  23. package/src/auto-gc.ts +98 -29
  24. package/src/auto-recall.ts +33 -4
  25. package/src/capture-engine.ts +372 -23
  26. package/src/cascade-forget.ts +3 -2
  27. package/src/cli.ts +107 -5
  28. package/src/compat.ts +22 -0
  29. package/src/confidence-tracker.ts +210 -47
  30. package/src/conflict-store.ts +12 -3
  31. package/src/consolidation-engine.ts +296 -8
  32. package/src/context-collapse-renderer.ts +222 -0
  33. package/src/context-composer-stable-selection.ts +5 -1
  34. package/src/context-composer.ts +239 -4
  35. package/src/context-reconstructor.ts +404 -0
  36. package/src/conversation-importer.ts +441 -0
  37. package/src/data-checkup.ts +232 -0
  38. package/src/decay-engine.ts +84 -0
  39. package/src/distill-lock.ts +122 -0
  40. package/src/doctor.ts +8 -10
  41. package/src/dream-pipeline.ts +227 -0
  42. package/src/emotion-detector.ts +90 -0
  43. package/src/eval.ts +3 -2
  44. package/src/forget-engine.ts +269 -0
  45. package/src/frequency-tracker.ts +175 -0
  46. package/src/graph-export.ts +644 -0
  47. package/src/ingest.ts +36 -6
  48. package/src/interference-detector.ts +187 -0
  49. package/src/kg-extractor.ts +15 -24
  50. package/src/kg-store.ts +2 -1
  51. package/src/language-hook.ts +67 -0
  52. package/src/llm-client.ts +289 -6
  53. package/src/mcp-server.ts +707 -80
  54. package/src/memory-assets.ts +22 -5
  55. package/src/memory-evolution.ts +365 -0
  56. package/src/memory-lint.ts +429 -0
  57. package/src/memory-output.ts +69 -0
  58. package/src/memory-schema.ts +74 -2
  59. package/src/narrative-schema.ts +79 -0
  60. package/src/narrative-tagger.ts +164 -0
  61. package/src/pii-detector.ts +98 -0
  62. package/src/post-retrieval-filter.ts +76 -0
  63. package/src/prediction-engine.ts +253 -0
  64. package/src/preference-slots.ts +65 -2
  65. package/src/prospective-memory.ts +215 -0
  66. package/src/query-expander.ts +67 -0
  67. package/src/recall-governor.ts +183 -0
  68. package/src/retention-policy.ts +90 -0
  69. package/src/retriever.ts +828 -55
  70. package/src/rif.ts +34 -8
  71. package/src/runtime-config.ts +23 -8
  72. package/src/scope-inventory.ts +4 -3
  73. package/src/scope-policy.ts +4 -0
  74. package/src/session-distiller.ts +307 -0
  75. package/src/session-output.ts +40 -0
  76. package/src/session-schema.ts +37 -0
  77. package/src/session-store.ts +7 -2
  78. package/src/skill-engine.ts +208 -0
  79. package/src/skill-promotion.ts +293 -0
  80. package/src/skill-schema.ts +39 -0
  81. package/src/store.ts +96 -10
  82. package/src/topic-tag.ts +66 -0
  83. package/src/tracker.ts +3 -1
  84. package/src/ui-server.ts +80 -8
  85. package/src/workflow-observation-scope-review.ts +2 -1
  86. package/src/workflow-observation-store.ts +2 -1
  87. package/src/write-verifier.ts +126 -0
  88. package/.claude-plugin/CLAUDE.md +0 -29
  89. package/.claude-plugin/marketplace.json +0 -18
  90. package/.github/workflows/ci.yml +0 -28
  91. package/.mcp.json +0 -9
  92. package/CHANGELOG.md +0 -51
  93. package/DEMO.md +0 -94
  94. package/EVAL.md +0 -89
  95. package/FAILURES.md +0 -75
  96. package/OPERATIONS.md +0 -59
  97. package/ROADMAP.md +0 -198
  98. package/assets/recallnest-workbench.png +0 -0
  99. package/assets/ui/app.js +0 -807
  100. package/assets/ui/index.html +0 -154
  101. package/assets/ui/styles.css +0 -605
  102. package/assets/wechat-qr.jpg +0 -0
  103. package/daily-reflections/2026-03-05.md +0 -15
  104. package/daily-reflections/2026-03-06.md +0 -15
  105. package/daily-reflections/2026-03-07.md +0 -7
  106. package/daily-reflections/2026-03-08.md +0 -34
  107. package/daily-reflections/2026-03-09.md +0 -35
  108. package/daily-reflections/2026-03-10.md +0 -36
  109. package/daily-reflections/2026-03-11.md +0 -35
  110. package/daily-reflections/2026-03-12.md +0 -36
  111. package/daily-reflections/2026-03-13.md +0 -35
  112. package/daily-reflections/2026-03-14.md +0 -35
  113. package/daily-reflections/2026-03-15.md +0 -35
  114. package/daily-reflections/2026-03-16.md +0 -41
  115. package/daily-reflections/2026-03-17.md +0 -10
  116. package/daily-reflections/2026-03-18.md +0 -10
  117. package/daily-reflections/2026-03-19.md +0 -10
  118. package/daily-reflections/2026-03-20.md +0 -10
  119. package/daily-reflections/2026-03-21.md +0 -10
  120. package/daily-reflections/2026-03-22.md +0 -10
  121. package/daily-reflections/2026-03-23.md +0 -10
  122. package/daily-reflections/2026-03-24.md +0 -10
  123. package/daily-reflections/2026-03-25.md +0 -32
  124. package/daily-reflections/2026-03-26.md +0 -37
  125. package/daily-reflections/2026-03-27.md +0 -35
  126. package/daily-reflections/2026-03-28.md +0 -35
  127. package/daily-reflections/2026-03-29.md +0 -35
  128. package/daily-reflections/2026-03-30.md +0 -36
  129. package/daily-reflections/2026-03-31.md +0 -35
  130. package/daily-reflections/2026-04-01.md +0 -35
  131. package/docs/api-reference.md +0 -1291
  132. package/docs/architecture.md +0 -168
  133. package/docs/memory-boundary-contract.md +0 -226
  134. package/docs/memory-categories.md +0 -119
  135. package/docs/pipeline-audit-ultramemory.md +0 -177
  136. package/docs/plans/2026-03-08-query-expansion.md +0 -270
  137. package/docs/plans/2026-03-16-continuity-layer.md +0 -196
  138. package/docs/plans/mcp-tiering-plan.md +0 -126
  139. package/docs/research/recall-quality-audit-2026-03-22.md +0 -153
  140. package/docs/self-evolution.md +0 -198
  141. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +0 -782
  142. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +0 -77
  143. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +0 -95
  144. package/eval/continuity/results-2026-03-16.md +0 -117
  145. package/eval/continuity/results-2026-03-19.md +0 -453
  146. package/eval/continuity/runbook.md +0 -110
  147. package/eval/continuity/seed-cases.md +0 -54
  148. package/eval/reports/2026-03-06-abstract-query-baseline.md +0 -96
  149. package/eval/reports/2026-03-06-aws-ssh-baseline.md +0 -82
  150. package/eval/reports/2026-03-06-baseline.md +0 -82
  151. package/eval/reports/query-expansion.md +0 -138
  152. package/lm +0 -19
  153. package/plan-v1.2-distributable.md +0 -105
  154. package/scripts/archive-distilled-junk.ts +0 -116
  155. package/scripts/backfill-l0l1l2.ts +0 -98
  156. package/scripts/blind-scan.ts +0 -588
  157. package/scripts/cleanup-step1-exact-dups.ts +0 -117
  158. package/scripts/cleanup-step2-template-dups.ts +0 -113
  159. package/scripts/cleanup-step3-stale-content.ts +0 -149
  160. package/scripts/cleanup-step4-orphan-scopes.ts +0 -131
  161. package/scripts/delete-distilled-junk.ts +0 -88
  162. package/scripts/distill-facts.ts +0 -305
  163. package/scripts/health-check.ts +0 -343
  164. package/scripts/incremental-ingest.sh +0 -71
  165. package/scripts/mlp-health-check.ts +0 -382
  166. package/scripts/pull-claude-desktop.ts +0 -515
  167. package/scripts/re-ingest-cc.sh +0 -66
  168. package/scripts/rebalance-memory-health.ts +0 -181
  169. package/scripts/run-desktop-ingest.sh +0 -11
  170. package/scripts/start-server.sh +0 -12
  171. package/scripts/viz-dashboard.html +0 -423
  172. package/scripts/viz-export.ts +0 -703
  173. package/src/__tests__/access-tracker-novelty.test.ts +0 -228
  174. package/src/__tests__/auto-consolidation.test.ts +0 -168
  175. package/src/__tests__/auto-recall.test.ts +0 -131
  176. package/src/__tests__/batch-dedup.test.ts +0 -65
  177. package/src/__tests__/capture-engine.test.ts +0 -1142
  178. package/src/__tests__/capture-heuristic.test.ts +0 -171
  179. package/src/__tests__/cascade-forget.test.ts +0 -210
  180. package/src/__tests__/category-min-scores.test.ts +0 -33
  181. package/src/__tests__/confidence-tracker.test.ts +0 -196
  182. package/src/__tests__/conflict-advisor.test.ts +0 -206
  183. package/src/__tests__/conflict-escalation.test.ts +0 -149
  184. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  185. package/src/__tests__/conflict-output.test.ts +0 -85
  186. package/src/__tests__/conflict-store.test.ts +0 -374
  187. package/src/__tests__/consolidation-engine.test.ts +0 -181
  188. package/src/__tests__/context-composer-pins.test.ts +0 -64
  189. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  190. package/src/__tests__/context-renderer.test.ts +0 -91
  191. package/src/__tests__/continuity-eval.test.ts +0 -275
  192. package/src/__tests__/continuity-schema.test.ts +0 -97
  193. package/src/__tests__/core-recall-eval.test.ts +0 -235
  194. package/src/__tests__/core-summary.test.ts +0 -109
  195. package/src/__tests__/dedup-actions.test.ts +0 -58
  196. package/src/__tests__/deterministic-id.test.ts +0 -39
  197. package/src/__tests__/doctor.test.ts +0 -303
  198. package/src/__tests__/drill-down.test.ts +0 -108
  199. package/src/__tests__/embedder.test.ts +0 -122
  200. package/src/__tests__/entity-resolver.test.ts +0 -58
  201. package/src/__tests__/event-segmenter.test.ts +0 -153
  202. package/src/__tests__/hotness-blend.test.ts +0 -76
  203. package/src/__tests__/ingest.test.ts +0 -360
  204. package/src/__tests__/kg-extractor.test.ts +0 -184
  205. package/src/__tests__/kg-store.test.ts +0 -198
  206. package/src/__tests__/llm-consolidation.test.ts +0 -226
  207. package/src/__tests__/memory-boundaries.test.ts +0 -237
  208. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  209. package/src/__tests__/memory-output.test.ts +0 -215
  210. package/src/__tests__/multi-vector.test.ts +0 -242
  211. package/src/__tests__/ppr-traversal.test.ts +0 -256
  212. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  213. package/src/__tests__/preference-matcher.test.ts +0 -230
  214. package/src/__tests__/preference-slots.test.ts +0 -106
  215. package/src/__tests__/prospective-memory.test.ts +0 -283
  216. package/src/__tests__/query-entity-detector.test.ts +0 -171
  217. package/src/__tests__/query-expander.test.ts +0 -34
  218. package/src/__tests__/recall-mode.test.ts +0 -64
  219. package/src/__tests__/result-synthesizer.test.ts +0 -198
  220. package/src/__tests__/resume-context.test.ts +0 -4743
  221. package/src/__tests__/retrieval-trace.test.ts +0 -213
  222. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  223. package/src/__tests__/retroactive-boost.test.ts +0 -223
  224. package/src/__tests__/scope-inventory.test.ts +0 -200
  225. package/src/__tests__/scope-policy.test.ts +0 -69
  226. package/src/__tests__/session-checkpoint.test.ts +0 -259
  227. package/src/__tests__/session-gc.test.ts +0 -215
  228. package/src/__tests__/store-scope-audit.test.ts +0 -81
  229. package/src/__tests__/temporal-parser.test.ts +0 -241
  230. package/src/__tests__/term-registry.test.ts +0 -121
  231. package/src/__tests__/tool-output-compressor.test.ts +0 -133
  232. package/src/__tests__/version-manager.test.ts +0 -213
  233. package/src/__tests__/workflow-observation.test.ts +0 -400
@@ -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
+ }