@animalabs/connectome-host 0.3.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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,747 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Import a claude.ai data export into conhost sessions.
4
+ *
5
+ * Each conversation in `conversations.json` becomes a new conhost session with
6
+ * an isolated Chronicle store, named after the original conversation. The
7
+ * conversation's messages are appended through context-manager's `MessageStore`
8
+ * so the resulting sessions are indistinguishable from native conhost sessions.
9
+ *
10
+ * Block-level fidelity:
11
+ * - text → TextContent
12
+ * - thinking → TextContent wrapped in <recovered_thinking>…</recovered_thinking>
13
+ * (original thinking + summaries preserved verbatim in metadata)
14
+ * - tool_use → ToolUseContent (claude.ai-internal tool names kept as-is;
15
+ * inert at replay since no module advertises them)
16
+ * - tool_result → ToolResultContent
17
+ * - attachments → leading <attachment …>{extracted_content}</attachment> text
18
+ * - files (no bytes) → trailing [image: name (file_uuid=…, bytes not in export)]
19
+ *
20
+ * Why wrapped <recovered_thinking> text and not native thinking blocks:
21
+ * The export omits cryptographic signatures (claude.ai never plumbed them).
22
+ * Probed empirically (scripts/test-historical-thinking.ts): the API rejects
23
+ * unsigned thinking blocks AND redacted_thinking with fabricated data. Wrapped
24
+ * text round-trips cleanly and lets the model run with extended thinking ON
25
+ * for the *new* turns (which is the cognitive mode the conversation was
26
+ * originally in on claude.ai web).
27
+ *
28
+ * Tree handling:
29
+ * The export records branches via `parent_message_uuid`. When a fork is
30
+ * present, the importer picks the canonical path by depth-first descent
31
+ * choosing, at each branch point, the subtree whose deepest leaf has the
32
+ * latest `created_at`. Orphan branches are discarded in v1 (the original
33
+ * conversation is preserved in `conversations.json` regardless).
34
+ *
35
+ * Run:
36
+ * bun scripts/import-claudeai-export.ts ../claudeai-export
37
+ *
38
+ * Options:
39
+ * --out <dir> Conhost data dir (default: ./data)
40
+ * --agent <name> Participant name for assistant turns. Default: "Claude"
41
+ * — the natural choice for claude.ai-derived sessions
42
+ * and what the bundled `claude-export-revive.json`
43
+ * recipe pins `agent.name` to. The chosen value is
44
+ * written into the per-session import-source sidecar,
45
+ * so warmup and conhost can read it back without
46
+ * re-passing the flag.
47
+ * --filter <regex> Only import conversations whose name matches (case-insens.)
48
+ * --dry-run Parse and report; don't write
49
+ */
50
+
51
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
52
+ import { join, resolve } from 'node:path';
53
+ import { JsStore } from '@animalabs/chronicle';
54
+ import { ContextManager } from '@animalabs/context-manager';
55
+ import type { ContentBlock } from '@animalabs/membrane';
56
+ import { SessionManager } from '../src/session-manager.js';
57
+ import { createLineReader } from './lib/line-reader.js';
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // CLI parsing
61
+ // ---------------------------------------------------------------------------
62
+
63
+ interface Opts {
64
+ exportDir: string;
65
+ outDir: string;
66
+ agentName: string;
67
+ filter?: RegExp;
68
+ dryRun: boolean;
69
+ interactive: boolean;
70
+ }
71
+
72
+ function parseArgs(argv: string[]): Opts {
73
+ const args = argv.slice(2);
74
+ if (args.length === 0 || args[0]?.startsWith('-')) {
75
+ console.error(
76
+ 'Usage: bun scripts/import-claudeai-export.ts <export-dir> [--out <dir>] [--agent <name>] [--filter <regex>] [--dry-run] [--no-interactive]',
77
+ );
78
+ process.exit(1);
79
+ }
80
+ const exportDir = resolve(args[0]!);
81
+ let outDir = resolve(process.cwd(), 'data');
82
+ let agentName = 'Claude';
83
+ let filter: RegExp | undefined;
84
+ let dryRun = false;
85
+ // Interactive by default if stdin is a TTY; --no-interactive forces off.
86
+ let interactive = !!process.stdin.isTTY;
87
+ for (let i = 1; i < args.length; i++) {
88
+ const a = args[i]!;
89
+ if (a === '--out') outDir = resolve(args[++i]!);
90
+ else if (a === '--agent') agentName = args[++i]!;
91
+ else if (a === '--filter') filter = new RegExp(args[++i]!, 'i');
92
+ else if (a === '--dry-run') dryRun = true;
93
+ else if (a === '--no-interactive') interactive = false;
94
+ else if (a === '--interactive') interactive = true;
95
+ else {
96
+ console.error(`Unknown arg: ${a}`);
97
+ process.exit(1);
98
+ }
99
+ }
100
+ return { exportDir, outDir, agentName, filter, dryRun, interactive };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Export schema (subset — only what we read)
105
+ // ---------------------------------------------------------------------------
106
+
107
+ interface ExportTextBlock {
108
+ type: 'text';
109
+ text: string;
110
+ citations?: unknown[];
111
+ start_timestamp?: string;
112
+ stop_timestamp?: string;
113
+ }
114
+ interface ExportThinkingBlock {
115
+ type: 'thinking';
116
+ thinking: string;
117
+ summaries?: Array<{ summary: string }>;
118
+ cut_off?: boolean;
119
+ start_timestamp?: string;
120
+ stop_timestamp?: string;
121
+ }
122
+ interface ExportToolUseBlock {
123
+ type: 'tool_use';
124
+ id: string;
125
+ name: string;
126
+ input: Record<string, unknown>;
127
+ integration_name?: string;
128
+ mcp_server_url?: string;
129
+ display_content?: unknown;
130
+ message?: string;
131
+ is_mcp_app?: boolean;
132
+ }
133
+ interface ExportToolResultBlock {
134
+ type: 'tool_result';
135
+ tool_use_id: string;
136
+ name?: string;
137
+ content: string | unknown[];
138
+ structured_content?: unknown;
139
+ display_content?: unknown;
140
+ is_error?: boolean;
141
+ integration_name?: string;
142
+ message?: string;
143
+ }
144
+ type ExportContentBlock =
145
+ | ExportTextBlock
146
+ | ExportThinkingBlock
147
+ | ExportToolUseBlock
148
+ | ExportToolResultBlock;
149
+
150
+ interface ExportAttachment {
151
+ file_name: string;
152
+ file_size?: number;
153
+ file_type?: string;
154
+ extracted_content?: string;
155
+ }
156
+ interface ExportFileRef {
157
+ file_uuid: string;
158
+ file_name: string;
159
+ }
160
+
161
+ interface ExportMessage {
162
+ uuid: string;
163
+ parent_message_uuid: string;
164
+ sender: 'human' | 'assistant';
165
+ text: string;
166
+ content: ExportContentBlock[];
167
+ attachments: ExportAttachment[];
168
+ files: ExportFileRef[];
169
+ created_at: string;
170
+ updated_at: string;
171
+ }
172
+
173
+ interface ExportConversation {
174
+ uuid: string;
175
+ name: string;
176
+ summary?: string;
177
+ created_at: string;
178
+ updated_at: string;
179
+ chat_messages: ExportMessage[];
180
+ }
181
+
182
+ const ROOT_PARENT = '00000000-0000-4000-8000-000000000000';
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Tree linearization
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /**
189
+ * Pick a canonical path through the message tree. For unbranched conversations
190
+ * this is just topological order. For branched ones, at each fork point we
191
+ * descend into the subtree whose deepest leaf has the latest `created_at`.
192
+ *
193
+ * Single DFS post-order populates a per-node `latestLeafTime` map so the main
194
+ * descent is O(n) — no repeated subtree walks at branch points.
195
+ */
196
+ export function linearize(messages: ExportMessage[]): { path: ExportMessage[]; branched: boolean } {
197
+ const byParent = new Map<string, ExportMessage[]>();
198
+ for (const m of messages) {
199
+ const arr = byParent.get(m.parent_message_uuid) ?? [];
200
+ arr.push(m);
201
+ byParent.set(m.parent_message_uuid, arr);
202
+ }
203
+
204
+ // Iterative post-order to avoid recursion-depth limits on very long
205
+ // chains, and to memoize each node's deepest-leaf time in one pass.
206
+ const latestLeafTime = new Map<string, string>();
207
+ // Build a flat post-order list by DFS from each root.
208
+ const roots = byParent.get(ROOT_PARENT) ?? [];
209
+ const stack: Array<{ msg: ExportMessage; childIdx: number }> = roots.map((r) => ({ msg: r, childIdx: 0 }));
210
+ const postOrder: ExportMessage[] = [];
211
+ while (stack.length > 0) {
212
+ const frame = stack[stack.length - 1]!;
213
+ const kids = byParent.get(frame.msg.uuid) ?? [];
214
+ if (frame.childIdx < kids.length) {
215
+ const next = kids[frame.childIdx++]!;
216
+ stack.push({ msg: next, childIdx: 0 });
217
+ } else {
218
+ postOrder.push(frame.msg);
219
+ stack.pop();
220
+ }
221
+ }
222
+ for (const node of postOrder) {
223
+ const kids = byParent.get(node.uuid) ?? [];
224
+ let best = node.created_at;
225
+ for (const k of kids) {
226
+ const t = latestLeafTime.get(k.uuid)!;
227
+ if (t > best) best = t;
228
+ }
229
+ latestLeafTime.set(node.uuid, best);
230
+ }
231
+
232
+ let branched = false;
233
+ const path: ExportMessage[] = [];
234
+ let cursorParent = ROOT_PARENT;
235
+ while (true) {
236
+ const children = byParent.get(cursorParent) ?? [];
237
+ if (children.length === 0) break;
238
+ if (children.length > 1) branched = true;
239
+ let pick = children[0]!;
240
+ let pickT = latestLeafTime.get(pick.uuid)!;
241
+ for (let i = 1; i < children.length; i++) {
242
+ const t = latestLeafTime.get(children[i]!.uuid)!;
243
+ if (t > pickT) {
244
+ pick = children[i]!;
245
+ pickT = t;
246
+ }
247
+ }
248
+ path.push(pick);
249
+ cursorParent = pick.uuid;
250
+ }
251
+
252
+ return { path, branched };
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Block transform
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Transform one export message's content blocks into Anthropic-shape blocks,
261
+ * fixing two claude.ai quirks along the way:
262
+ *
263
+ * - Internal tools (web_search, web_fetch, conversation_search, artifacts,
264
+ * etc.) have `tool_use.id === null` and the paired `tool_result` has
265
+ * `tool_use_id === null`. The API requires non-empty strings. We
266
+ * synthesize stable IDs (`toolu_imported_<msg.uuid>_<blockIdx>`) and
267
+ * pair the next null-ID tool_result to the most recent null-ID tool_use
268
+ * in the same message via FIFO queue.
269
+ *
270
+ * - tool_use + tool_result get bundled in a single assistant message.
271
+ * API rules require tool_result to live in the *next* user message.
272
+ * The block-level transform handled here only emits the blocks; the
273
+ * message-level split is done in `splitMixedToolMessages` so the
274
+ * two concerns stay independently testable.
275
+ */
276
+ export function transformContent(msg: ExportMessage): ContentBlock[] {
277
+ const out: ContentBlock[] = [];
278
+
279
+ // Prepend attachment texts (user-side)
280
+ for (const att of msg.attachments ?? []) {
281
+ if (att.extracted_content) {
282
+ const header = `<attachment name="${escapeAttr(att.file_name || 'attachment')}"${
283
+ att.file_type ? ` type="${escapeAttr(att.file_type)}"` : ''
284
+ }${att.file_size ? ` size="${att.file_size}"` : ''}>`;
285
+ out.push({
286
+ type: 'text',
287
+ text: `${header}\n${att.extracted_content}\n</attachment>`,
288
+ });
289
+ }
290
+ }
291
+
292
+ // Queue of synthetic tool_use IDs awaiting pairing with a null-ID
293
+ // tool_result. FIFO because tool cycles are sequentially well-ordered
294
+ // within a single source message.
295
+ const pendingToolUseIds: string[] = [];
296
+
297
+ for (const [blockIdx, block] of (msg.content ?? []).entries()) {
298
+ switch (block.type) {
299
+ case 'text':
300
+ if (block.text) out.push({ type: 'text', text: block.text });
301
+ break;
302
+
303
+ case 'thinking': {
304
+ // Full thinking text is the load-bearing form: it's what the API
305
+ // sees at replay and what the model reads as its prior reasoning.
306
+ // The sidecar import-source.json captures provenance separately,
307
+ // so we don't duplicate into message metadata.
308
+ out.push({
309
+ type: 'text',
310
+ text: `<recovered_thinking>\n${block.thinking}\n</recovered_thinking>`,
311
+ });
312
+ break;
313
+ }
314
+
315
+ case 'tool_use': {
316
+ let id = (block.id as string | null | undefined) ?? '';
317
+ if (!id) {
318
+ id = `toolu_imported_${msg.uuid}_${blockIdx}`;
319
+ pendingToolUseIds.push(id);
320
+ }
321
+ out.push({
322
+ type: 'tool_use',
323
+ id,
324
+ name: block.name,
325
+ input: block.input ?? {},
326
+ });
327
+ break;
328
+ }
329
+
330
+ case 'tool_result': {
331
+ // Pair with the nearest preceding null-ID tool_use in the same
332
+ // message. If the source provided a tool_use_id, trust it. If
333
+ // neither pairing nor source ID is available, emit an orphan
334
+ // marker rather than null (which crashes the API).
335
+ let toolUseId = (block.tool_use_id as string | null | undefined) ?? '';
336
+ if (!toolUseId) {
337
+ toolUseId = pendingToolUseIds.shift() ?? `toolu_imported_orphan_${msg.uuid}_${blockIdx}`;
338
+ }
339
+ // Membrane's ToolResultContent.content is string | ContentBlock[].
340
+ // The export's `content` is usually a string or an array of {type,text}
341
+ // sub-blocks; coerce conservatively to a string when in doubt so the
342
+ // round-trip is safe.
343
+ let content: string | ContentBlock[];
344
+ if (typeof block.content === 'string') {
345
+ content = block.content;
346
+ } else if (Array.isArray(block.content)) {
347
+ // Coerce sub-blocks to text where possible.
348
+ const subTexts: ContentBlock[] = [];
349
+ for (const sub of block.content as Array<Record<string, unknown>>) {
350
+ if (sub.type === 'text' && typeof sub.text === 'string') {
351
+ subTexts.push({ type: 'text', text: sub.text });
352
+ }
353
+ }
354
+ content = subTexts.length > 0 ? subTexts : JSON.stringify(block.content);
355
+ } else {
356
+ content = JSON.stringify(block.content);
357
+ }
358
+ out.push({
359
+ type: 'tool_result',
360
+ toolUseId,
361
+ content,
362
+ ...(block.is_error ? { isError: true } : {}),
363
+ });
364
+ break;
365
+ }
366
+
367
+ default: {
368
+ // Unknown block type — claude.ai may add new ones (server_tool_use,
369
+ // citations, etc.) without notice. Don't drop on the floor; emit a
370
+ // grep-able marker so the operator can audit what was lost.
371
+ const unknownType = (block as { type?: unknown }).type;
372
+ out.push({
373
+ type: 'text',
374
+ text: `[unknown_block type=${JSON.stringify(unknownType)}]\n${JSON.stringify(block)}`,
375
+ });
376
+ }
377
+ }
378
+ }
379
+
380
+ // Trailing file placeholders (images, etc., bytes not in dump)
381
+ for (const f of msg.files ?? []) {
382
+ out.push({
383
+ type: 'text',
384
+ text: `[image: ${f.file_name} (file_uuid=${f.file_uuid}, bytes not in export)]`,
385
+ });
386
+ }
387
+
388
+ return out;
389
+ }
390
+
391
+ /**
392
+ * claude.ai stores an entire internal-tool cycle (tool_use → tool_result)
393
+ * as consecutive blocks in one assistant message. The Anthropic API requires
394
+ * tool_use blocks in assistant turns and tool_result blocks in user turns,
395
+ * with each tool_result in the *immediately following* user message.
396
+ *
397
+ * Walks `blocks` and, whenever a tool_result is encountered in a non-user
398
+ * source message, flushes the preceding blocks as a `sourceParticipant`
399
+ * message, emits the tool_result alone as a `user` message, then continues
400
+ * collecting blocks for another `sourceParticipant` message. Adjacent
401
+ * tool_results get merged into a single user message (API-legal).
402
+ *
403
+ * If the source is already 'user' (claude.ai never produces this, but be
404
+ * safe), no splitting is needed and the input is returned unchanged.
405
+ */
406
+ export function splitMixedToolMessages(
407
+ blocks: ContentBlock[],
408
+ sourceParticipant: string,
409
+ ): Array<{ participant: string; content: ContentBlock[] }> {
410
+ if (sourceParticipant === 'user' || blocks.length === 0) {
411
+ return blocks.length === 0 ? [] : [{ participant: sourceParticipant, content: blocks }];
412
+ }
413
+ const out: Array<{ participant: string; content: ContentBlock[] }> = [];
414
+ let preTool: ContentBlock[] = [];
415
+ let pendingResults: ContentBlock[] = [];
416
+ const flushPre = () => {
417
+ if (preTool.length > 0) {
418
+ out.push({ participant: sourceParticipant, content: preTool });
419
+ preTool = [];
420
+ }
421
+ };
422
+ const flushResults = () => {
423
+ if (pendingResults.length > 0) {
424
+ out.push({ participant: 'user', content: pendingResults });
425
+ pendingResults = [];
426
+ }
427
+ };
428
+ for (const block of blocks) {
429
+ if (block.type === 'tool_result') {
430
+ flushPre();
431
+ pendingResults.push(block);
432
+ } else {
433
+ flushResults();
434
+ preTool.push(block);
435
+ }
436
+ }
437
+ flushResults();
438
+ flushPre();
439
+ return out;
440
+ }
441
+
442
+ function escapeAttr(s: string): string {
443
+ // & must be replaced first so the others' replacements aren't double-escaped.
444
+ return s
445
+ .replace(/&/g, '&amp;')
446
+ .replace(/"/g, '&quot;')
447
+ .replace(/</g, '&lt;')
448
+ .replace(/>/g, '&gt;');
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Main
453
+ // ---------------------------------------------------------------------------
454
+
455
+ async function importConversation(
456
+ conv: ExportConversation,
457
+ opts: Opts,
458
+ sessionMgr: SessionManager,
459
+ ): Promise<{ sessionId: string; messageCount: number; branched: boolean } | null> {
460
+ const { path, branched } = linearize(conv.chat_messages);
461
+ if (path.length === 0) return null;
462
+
463
+ if (opts.dryRun) {
464
+ return { sessionId: '(dry-run)', messageCount: path.length, branched };
465
+ }
466
+
467
+ const meta = sessionMgr.createSession(conv.name || `Imported ${conv.uuid.slice(0, 8)}`);
468
+ const storePath = sessionMgr.getStorePath(meta.id);
469
+ const store = JsStore.openOrCreate({ path: storePath });
470
+ try {
471
+ const cm = await ContextManager.open({ store });
472
+ try {
473
+ for (const msg of path) {
474
+ const blocks = transformContent(msg);
475
+ if (blocks.length === 0) continue; // skip degenerate messages
476
+ const sourceParticipant = msg.sender === 'assistant' ? opts.agentName : 'user';
477
+ // claude.ai bundles tool cycles into single assistant turns; the API
478
+ // requires tool_results in their own user turns. Split here so the
479
+ // Chronicle store holds API-shape messages from the start.
480
+ const messages = splitMixedToolMessages(blocks, sourceParticipant);
481
+ for (const [idx, m] of messages.entries()) {
482
+ cm.addMessage(m.participant, m.content, {
483
+ sourceId: messages.length > 1 ? `${msg.uuid}#${idx}` : msg.uuid,
484
+ exportSource: {
485
+ conversationUuid: conv.uuid,
486
+ originalParent: msg.parent_message_uuid,
487
+ createdAt: msg.created_at,
488
+ updatedAt: msg.updated_at,
489
+ originalSender: msg.sender,
490
+ ...(messages.length > 1 ? { splitIndex: idx, splitTotal: messages.length } : {}),
491
+ },
492
+ });
493
+ }
494
+ }
495
+ } finally {
496
+ await cm.close?.();
497
+ }
498
+ } finally {
499
+ store.close?.();
500
+ }
501
+
502
+ // Sidecar provenance file (next to the session dir, not inside the store)
503
+ const sidecarPath = join(storePath, '..', `${meta.id}.import-source.json`);
504
+ writeFileSync(
505
+ sidecarPath,
506
+ JSON.stringify(
507
+ {
508
+ conversationUuid: conv.uuid,
509
+ name: conv.name,
510
+ summary: conv.summary ?? null,
511
+ createdAt: conv.created_at,
512
+ updatedAt: conv.updated_at,
513
+ // The participant name assigned to assistant turns at import time.
514
+ // Recorded here so warmup and conhost can read it back and stay in
515
+ // sync without depending on a CLI flag the operator might forget.
516
+ // Bug source: warmup using its own default left summaries in one
517
+ // strategy namespace while the framework read from another.
518
+ agentName: opts.agentName,
519
+ originalMessageCount: conv.chat_messages.length,
520
+ importedMessageCount: path.length,
521
+ branched,
522
+ importedAt: new Date().toISOString(),
523
+ },
524
+ null,
525
+ 2,
526
+ ) + '\n',
527
+ );
528
+
529
+ // Update messageCount on the session entry so /session listings are accurate
530
+ const idx = sessionMgr.load();
531
+ if (idx.sessions[meta.id]) {
532
+ idx.sessions[meta.id]!.messageCount = path.length;
533
+ sessionMgr.save(idx);
534
+ }
535
+
536
+ return { sessionId: meta.id, messageCount: path.length, branched };
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // Interactive selection UI
541
+ // ---------------------------------------------------------------------------
542
+
543
+ function formatRow(idx: number, selected: boolean, conv: ExportConversation): string {
544
+ const id6 = conv.uuid.replace(/-/g, '').slice(0, 6);
545
+ const date = formatShortDate(conv.updated_at || conv.created_at);
546
+ const msgs = (conv.chat_messages?.length ?? 0).toString().padStart(4);
547
+ const name = (conv.name || '(unnamed)').slice(0, 64);
548
+ const mark = selected ? 'x' : ' ';
549
+ return ` [${mark}] ${idx.toString().padStart(3)} ${id6} ${date} ${msgs}msg ${name}`;
550
+ }
551
+
552
+ function formatShortDate(iso: string | undefined): string {
553
+ if (!iso) return ' ';
554
+ const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
555
+ const d = new Date(iso);
556
+ if (isNaN(d.getTime())) return ' ';
557
+ return `${months[d.getMonth()]} ${d.getDate().toString().padStart(2, ' ')} ${d.getFullYear().toString().slice(2)}`;
558
+ }
559
+
560
+ /** Parse comma-and-range index spec like "1,3-5,7" into a set of 1-based indices. */
561
+ export function parseToggleSpec(input: string, max: number): Set<number> {
562
+ const out = new Set<number>();
563
+ for (const piece of input.split(/[,\s]+/)) {
564
+ if (!piece) continue;
565
+ const dash = piece.indexOf('-');
566
+ if (dash > 0) {
567
+ const lo = parseInt(piece.slice(0, dash), 10);
568
+ const hi = parseInt(piece.slice(dash + 1), 10);
569
+ if (isNaN(lo) || isNaN(hi)) continue;
570
+ for (let i = Math.max(1, lo); i <= Math.min(max, hi); i++) out.add(i);
571
+ } else {
572
+ const n = parseInt(piece, 10);
573
+ if (!isNaN(n) && n >= 1 && n <= max) out.add(n);
574
+ }
575
+ }
576
+ return out;
577
+ }
578
+
579
+ // createLineReader is shared via scripts/lib/line-reader.ts (see imports above).
580
+
581
+ async function chooseInteractively(conversations: ExportConversation[]): Promise<ExportConversation[] | null> {
582
+ const reader = createLineReader();
583
+ try {
584
+ // Top-level menu
585
+ while (true) {
586
+ const raw = await reader.nextLine(
587
+ `\n${conversations.length} conversation(s) found. Import [A]ll, [C]hoose, e[X]it? `,
588
+ );
589
+ if (raw === null) return null;
590
+ const ans = raw.trim().toLowerCase();
591
+ if (ans === '' || ans === 'a') return conversations;
592
+ if (ans === 'x' || ans === 'q') return null;
593
+ if (ans === 'c') break;
594
+ console.log('Please enter A, C, or X.');
595
+ }
596
+
597
+ // Toggle UI
598
+ const selected = new Array<boolean>(conversations.length).fill(false);
599
+ const render = () => {
600
+ console.log('');
601
+ for (let i = 0; i < conversations.length; i++) {
602
+ console.log(formatRow(i + 1, selected[i]!, conversations[i]!));
603
+ }
604
+ const count = selected.filter((s) => s).length;
605
+ console.log(`\n${count}/${conversations.length} selected.`);
606
+ };
607
+ render();
608
+ console.log(
609
+ 'Toggle: number(s)/range (e.g. "1,3-5"), "a" all, "n" none, "i" invert, "l" relist, [enter] commit, "x" abort.',
610
+ );
611
+ while (true) {
612
+ const raw = await reader.nextLine('> ');
613
+ if (raw === null) break; // EOF == commit
614
+ const input = raw.trim();
615
+ if (input === '') break;
616
+ const cmd = input.toLowerCase();
617
+ if (cmd === 'x' || cmd === 'q') return null;
618
+ if (cmd === 'a') {
619
+ selected.fill(true);
620
+ render();
621
+ continue;
622
+ }
623
+ if (cmd === 'n') {
624
+ selected.fill(false);
625
+ render();
626
+ continue;
627
+ }
628
+ if (cmd === 'i') {
629
+ for (let i = 0; i < selected.length; i++) selected[i] = !selected[i];
630
+ render();
631
+ continue;
632
+ }
633
+ if (cmd === 'l') {
634
+ render();
635
+ continue;
636
+ }
637
+ const toToggle = parseToggleSpec(input, conversations.length);
638
+ if (toToggle.size === 0) {
639
+ console.log(' (no valid indices in input)');
640
+ continue;
641
+ }
642
+ for (const i of toToggle) selected[i - 1] = !selected[i - 1];
643
+ // Show what just toggled, not the full list — keeps the scroll usable.
644
+ const summary = [...toToggle]
645
+ .sort((a, b) => a - b)
646
+ .map((i) => `${i}${selected[i - 1] ? '✓' : '·'}`)
647
+ .join(' ');
648
+ const count = selected.filter((s) => s).length;
649
+ console.log(` toggled: ${summary} (${count}/${conversations.length} selected)`);
650
+ }
651
+
652
+ const chosen = conversations.filter((_, i) => selected[i]);
653
+ if (chosen.length === 0) {
654
+ console.log('Nothing selected — aborting.');
655
+ return null;
656
+ }
657
+ return chosen;
658
+ } finally {
659
+ reader.close();
660
+ }
661
+ }
662
+
663
+ async function main() {
664
+ const opts = parseArgs(process.argv);
665
+ const convPath = join(opts.exportDir, 'conversations.json');
666
+ if (!existsSync(convPath)) {
667
+ console.error(`No conversations.json at ${convPath}`);
668
+ process.exit(1);
669
+ }
670
+ const conversations: ExportConversation[] = JSON.parse(readFileSync(convPath, 'utf-8'));
671
+
672
+ const afterRegex = opts.filter
673
+ ? conversations.filter((c) => opts.filter!.test(c.name))
674
+ : conversations;
675
+
676
+ let filtered: ExportConversation[];
677
+ if (opts.interactive) {
678
+ const chosen = await chooseInteractively(afterRegex);
679
+ if (chosen === null) {
680
+ console.log('Aborted.');
681
+ process.exit(0);
682
+ }
683
+ filtered = chosen;
684
+ } else {
685
+ filtered = afterRegex;
686
+ }
687
+
688
+ console.log(
689
+ `\nImporting ${filtered.length}/${conversations.length} conversation(s) into ${opts.outDir}${
690
+ opts.dryRun ? ' (DRY RUN)' : ''
691
+ }\n`,
692
+ );
693
+
694
+ const sessionMgr = new SessionManager(opts.outDir);
695
+
696
+ // Snapshot pre-import activeSessionId so a bulk import doesn't silently
697
+ // steal the operator's working session. createSession() unconditionally
698
+ // sets activeSessionId; after a 24-convo import we'd land on whichever
699
+ // conversation was last in the file. Restored at the end.
700
+ const preImportActive = sessionMgr.load().activeSessionId || null;
701
+
702
+ let succeeded = 0;
703
+ let branchedCount = 0;
704
+ for (const conv of filtered) {
705
+ try {
706
+ const res = await importConversation(conv, opts, sessionMgr);
707
+ if (!res) {
708
+ console.log(` SKIP ${conv.name} (no messages)`);
709
+ continue;
710
+ }
711
+ const tag = res.branched ? ' [branched: kept latest-leaf path]' : '';
712
+ console.log(
713
+ ` ${res.sessionId.padEnd(10)} ${String(res.messageCount).padStart(4)} msgs ${conv.name}${tag}`,
714
+ );
715
+ succeeded++;
716
+ if (res.branched) branchedCount++;
717
+ } catch (err) {
718
+ console.error(` FAIL ${conv.name}: ${(err as Error).message}`);
719
+ }
720
+ }
721
+
722
+ // Restore the pre-import active session (no-op if there wasn't one — but
723
+ // we still leave activeSessionId as the last-imported id rather than the
724
+ // first, which feels less wrong if the operator had no active session).
725
+ if (preImportActive && !opts.dryRun) {
726
+ try {
727
+ sessionMgr.setActiveSession(preImportActive);
728
+ } catch {
729
+ // Pre-import active session may have been deleted; leave default.
730
+ }
731
+ }
732
+
733
+ console.log(
734
+ `\n${succeeded}/${filtered.length} imported${branchedCount > 0 ? `, ${branchedCount} had branches (linearized)` : ''}.`,
735
+ );
736
+ if (!opts.dryRun) {
737
+ console.log(`\nOpen one with:`);
738
+ console.log(` bun src/index.ts <recipe.json> --session <name-or-id>`);
739
+ }
740
+ }
741
+
742
+ if (import.meta.main) {
743
+ main().catch((e) => {
744
+ console.error(e);
745
+ process.exit(1);
746
+ });
747
+ }