@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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- 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, '&')
|
|
446
|
+
.replace(/"/g, '"')
|
|
447
|
+
.replace(/</g, '<')
|
|
448
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|