@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,53 @@
1
+ /**
2
+ * Stdin line reader using readline's 'line' event. Robust to piped input
3
+ * (Bun 1.3's readline.question / readline/promises.question hang at 99% CPU
4
+ * on subsequent calls when stdin is a pipe). Keep one reader instance alive
5
+ * for the whole script's main() — close/re-open per question is also flaky.
6
+ *
7
+ * Shared between scripts/evacuator.ts and scripts/import-claudeai-export.ts;
8
+ * if the upstream Bun bug ever gets fixed there is exactly one place to
9
+ * revert.
10
+ */
11
+ import { createInterface } from 'node:readline';
12
+
13
+ export interface LineReader {
14
+ nextLine(prompt?: string): Promise<string | null>;
15
+ close(): void;
16
+ }
17
+
18
+ export function createLineReader(): LineReader {
19
+ const buf: string[] = [];
20
+ let resolveNext: ((s: string | null) => void) | null = null;
21
+ let closed = false;
22
+ const rl = createInterface({ input: process.stdin, terminal: false });
23
+ rl.on('line', (line: string) => {
24
+ if (resolveNext) {
25
+ const r = resolveNext;
26
+ resolveNext = null;
27
+ r(line);
28
+ } else {
29
+ buf.push(line);
30
+ }
31
+ });
32
+ rl.on('close', () => {
33
+ closed = true;
34
+ if (resolveNext) {
35
+ const r = resolveNext;
36
+ resolveNext = null;
37
+ r(null);
38
+ }
39
+ });
40
+ return {
41
+ nextLine(prompt?: string) {
42
+ if (prompt) process.stdout.write(prompt);
43
+ return new Promise<string | null>((resolve) => {
44
+ if (buf.length > 0) resolve(buf.shift()!);
45
+ else if (closed) resolve(null);
46
+ else resolveNext = resolve;
47
+ });
48
+ },
49
+ close() {
50
+ rl.close();
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Probe whether Anthropic's API accepts historical thinking blocks without
4
+ * cryptographic signatures, when the current request has extended thinking
5
+ * enabled.
6
+ *
7
+ * Context: claude.ai web export contains thinking text but no signatures
8
+ * (claude.ai never plumbed signatures into the export pipeline). To resume
9
+ * those conversations via API with extended thinking on — preserving the
10
+ * model's cognitive mode — we need to know which of these the API tolerates:
11
+ *
12
+ * case A: signature field omitted
13
+ * case B: signature field empty string ""
14
+ * case C: thinking wrapped as <recovered_thinking> text (control — must work)
15
+ * case D: redacted_thinking block (alt encoding the API natively supports)
16
+ *
17
+ * Cases that succeed unlock the corresponding import strategy.
18
+ *
19
+ * Last run: 2026-05-13 against claude-sonnet-4-5-20250929. Results:
20
+ * A_no_signature → 400 invalid_request_error "signature: Field required"
21
+ * B_empty_signature → 400 invalid_request_error "Invalid `signature` in `thinking` block"
22
+ * C_wrapped_text → 200 OK; response itself contained native thinking
23
+ * D_redacted_thinking → 400 invalid_request_error "Invalid `data` in `redacted_thinking`"
24
+ * Conclusion: signatures are server-generated HMACs, cryptographically validated,
25
+ * cannot be forged or stripped. Wrapped text is the only path that round-trips.
26
+ * Re-run if Anthropic loosens this in future API versions.
27
+ *
28
+ * Run: ANTHROPIC_API_KEY=sk-... bun scripts/test-historical-thinking.ts
29
+ */
30
+
31
+ const API_KEY = process.env.ANTHROPIC_API_KEY;
32
+ if (!API_KEY) {
33
+ console.error('Set ANTHROPIC_API_KEY');
34
+ process.exit(1);
35
+ }
36
+
37
+ const MODEL = process.env.MODEL ?? 'claude-sonnet-4-5-20250929';
38
+ const ENDPOINT = 'https://api.anthropic.com/v1/messages';
39
+
40
+ // A fabricated past turn: user asked something, assistant thought, then spoke.
41
+ // This mirrors the shape of an exported claude.ai assistant message.
42
+ const FAKE_PAST_THINKING =
43
+ 'The user is asking about prime numbers. The smallest prime is 2 — sometimes ' +
44
+ 'people forget 1 is not prime by convention. I should give the answer plainly.';
45
+ const FAKE_PAST_TEXT = 'The smallest prime number is 2.';
46
+
47
+ interface Case {
48
+ name: string;
49
+ description: string;
50
+ assistantBlocks: unknown[];
51
+ }
52
+
53
+ const CASES: Case[] = [
54
+ {
55
+ name: 'A_no_signature',
56
+ description: 'thinking block with signature field omitted entirely',
57
+ assistantBlocks: [
58
+ { type: 'thinking', thinking: FAKE_PAST_THINKING },
59
+ { type: 'text', text: FAKE_PAST_TEXT },
60
+ ],
61
+ },
62
+ {
63
+ name: 'B_empty_signature',
64
+ description: 'thinking block with signature: ""',
65
+ assistantBlocks: [
66
+ { type: 'thinking', thinking: FAKE_PAST_THINKING, signature: '' },
67
+ { type: 'text', text: FAKE_PAST_TEXT },
68
+ ],
69
+ },
70
+ {
71
+ name: 'C_wrapped_text_control',
72
+ description: 'recovered_thinking as wrapped text (must succeed)',
73
+ assistantBlocks: [
74
+ {
75
+ type: 'text',
76
+ text: `<recovered_thinking>${FAKE_PAST_THINKING}</recovered_thinking>\n\n${FAKE_PAST_TEXT}`,
77
+ },
78
+ ],
79
+ },
80
+ {
81
+ name: 'D_redacted_thinking',
82
+ description: 'redacted_thinking block (data field is base64-ish placeholder)',
83
+ assistantBlocks: [
84
+ { type: 'redacted_thinking', data: Buffer.from('unrecoverable').toString('base64') },
85
+ { type: 'text', text: FAKE_PAST_TEXT },
86
+ ],
87
+ },
88
+ ];
89
+
90
+ async function runCase(c: Case): Promise<{ ok: boolean; status: number; body: string }> {
91
+ const body = {
92
+ model: MODEL,
93
+ max_tokens: 4096,
94
+ temperature: 1,
95
+ thinking: { type: 'enabled', budget_tokens: 1024 },
96
+ messages: [
97
+ { role: 'user', content: 'What is the smallest prime number?' },
98
+ { role: 'assistant', content: c.assistantBlocks },
99
+ { role: 'user', content: 'And what is the next one after that?' },
100
+ ],
101
+ };
102
+ const res = await fetch(ENDPOINT, {
103
+ method: 'POST',
104
+ headers: {
105
+ 'x-api-key': API_KEY!,
106
+ 'anthropic-version': '2023-06-01',
107
+ 'content-type': 'application/json',
108
+ },
109
+ body: JSON.stringify(body),
110
+ });
111
+ const text = await res.text();
112
+ return { ok: res.ok, status: res.status, body: text };
113
+ }
114
+
115
+ function summarize(body: string, ok: boolean): string {
116
+ try {
117
+ const parsed = JSON.parse(body);
118
+ if (ok) {
119
+ const blocks = (parsed.content ?? []) as Array<{ type: string; text?: string }>;
120
+ const reply = blocks.find((b) => b.type === 'text')?.text ?? '<no text>';
121
+ const hadThinking = blocks.some((b) => b.type === 'thinking');
122
+ return `OK; reply=${JSON.stringify(reply.slice(0, 80))}; thinking_in_response=${hadThinking}`;
123
+ }
124
+ return `${parsed.error?.type ?? 'error'}: ${parsed.error?.message ?? body.slice(0, 200)}`;
125
+ } catch {
126
+ return body.slice(0, 200);
127
+ }
128
+ }
129
+
130
+ async function main() {
131
+ console.log(`Model: ${MODEL}\n`);
132
+ for (const c of CASES) {
133
+ process.stdout.write(`[${c.name}] ${c.description} ... `);
134
+ try {
135
+ const { ok, status, body } = await runCase(c);
136
+ console.log(`${ok ? 'PASS' : 'FAIL'} (${status}) — ${summarize(body, ok)}\n`);
137
+ } catch (err) {
138
+ console.log(`THROW — ${(err as Error).message}\n`);
139
+ }
140
+ }
141
+ }
142
+
143
+ if (import.meta.main) {
144
+ main().catch((e) => {
145
+ console.error(e);
146
+ process.exit(1);
147
+ });
148
+ }
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Warmup a conhost session: drive its AutobiographicalStrategy to convergence
4
+ * before the session is opened for real continuation.
5
+ *
6
+ * Bulk-imported sessions (from claude.ai exports, etc.) land in Chronicle with
7
+ * thousands of raw messages and no L1/L2/L3 summaries. At runtime, autobio's
8
+ * uncompressed-fallback would emit everything raw at first compile, blowing
9
+ * the context window. This script pre-computes all summaries so the session
10
+ * is openable.
11
+ *
12
+ * Compression is driven by the same model used at conversation time
13
+ * (Sonnet 4.5 by default) — autobio's prompts are explicitly first-person
14
+ * ("describe it as you would to yourself"), so the summarizer is literally
15
+ * writing the friend's-Claude's own diary. Haiku here would be a different
16
+ * voice wearing the same name.
17
+ *
18
+ * Resumable: autobio persists its compression and merge queues to Chronicle.
19
+ * If interrupted, re-running this script picks up where it left off.
20
+ *
21
+ * Run:
22
+ * bun scripts/warmup-session.ts <session-name-or-id>
23
+ *
24
+ * Options:
25
+ * --data-dir <dir> Conhost data dir (default: ./data)
26
+ * --model <id> Compression model (default: claude-sonnet-4-5-20250929)
27
+ * --agent <name> Participant name for assistant turns. If omitted,
28
+ * read from the import-source sidecar written by
29
+ * `import-claudeai-export.ts`; falls back to "Claude"
30
+ * if neither is available. MUST match the value the
31
+ * session was imported with — Membrane formats the
32
+ * assistant role by string-comparing message
33
+ * participants against this name.
34
+ * --max-spend <usd> Soft cap; halt gracefully if cost exceeds this
35
+ * --l1-budget <n> L1 budget tokens (passed to autobio)
36
+ * --l2-budget <n> Likewise L2
37
+ * --l3-budget <n> Likewise L3
38
+ * --merge-threshold <n> L1→L2 / L2→L3 merge threshold (default: 6)
39
+ */
40
+
41
+ import { resolve } from 'node:path';
42
+ import { JsStore } from '@animalabs/chronicle';
43
+ import { ContextManager } from '@animalabs/context-manager';
44
+ import { AutobiographicalStrategy } from '@animalabs/agent-framework';
45
+ import { Membrane, AnthropicAdapter, type NormalizedResponse } from '@animalabs/membrane';
46
+ import { SessionManager } from '../src/session-manager.js';
47
+ import { resolveAgentName } from '../src/agent-name.js';
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Pricing (approximate, USD per 1M tokens). Used for the --max-spend gate
51
+ // and the on-screen running cost.
52
+ // Keep in sync with any canonical billing source if/when one is consolidated
53
+ // (per memory: project_billing_tracker.md). Stale entries here only affect
54
+ // the cost displayed during warmup; the actual spend is whatever Anthropic
55
+ // charges.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const PRICING: Record<string, { input: number; output: number }> = {
59
+ 'claude-sonnet-4-5-20250929': { input: 3.0, output: 15.0 },
60
+ 'claude-opus-4-7': { input: 15.0, output: 75.0 },
61
+ 'claude-haiku-4-5-20251001': { input: 0.8, output: 4.0 },
62
+ };
63
+
64
+ function priceOf(model: string): { input: number; output: number } {
65
+ return PRICING[model] ?? { input: 3.0, output: 15.0 };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // CLI
70
+ // ---------------------------------------------------------------------------
71
+
72
+ interface Opts {
73
+ sessionRef: string;
74
+ dataDir: string;
75
+ model: string;
76
+ /**
77
+ * Explicit `--agent <name>` from the CLI. When absent (undefined), the
78
+ * runner falls back to the session's import-source sidecar, then to
79
+ * "Claude". Distinguishing "not given" from "given as 'agent'" lets the
80
+ * sidecar (the durable record of what the importer wrote) override a
81
+ * stale default without overriding a deliberate operator choice.
82
+ */
83
+ agentName?: string;
84
+ maxSpend: number | null;
85
+ l1Budget?: number;
86
+ l2Budget?: number;
87
+ l3Budget?: number;
88
+ mergeThreshold?: number;
89
+ }
90
+
91
+ function parseArgs(argv: string[]): Opts {
92
+ const args = argv.slice(2);
93
+ if (args.length === 0 || args[0]?.startsWith('-')) {
94
+ console.error(
95
+ 'Usage: bun scripts/warmup-session.ts <session-name-or-id> [--data-dir <dir>] [--model <id>] [--agent <name>] [--max-spend <usd>] [--l1-budget <n>] [--l2-budget <n>] [--l3-budget <n>] [--merge-threshold <n>]',
96
+ );
97
+ process.exit(1);
98
+ }
99
+ const opts: Opts = {
100
+ sessionRef: args[0]!,
101
+ dataDir: resolve(process.cwd(), 'data'),
102
+ model: 'claude-sonnet-4-5-20250929',
103
+ maxSpend: null,
104
+ };
105
+ for (let i = 1; i < args.length; i++) {
106
+ const a = args[i]!;
107
+ if (a === '--data-dir') opts.dataDir = resolve(args[++i]!);
108
+ else if (a === '--model') opts.model = args[++i]!;
109
+ else if (a === '--agent') opts.agentName = args[++i]!;
110
+ else if (a === '--max-spend') opts.maxSpend = parseFloat(args[++i]!);
111
+ else if (a === '--l1-budget') opts.l1Budget = parseInt(args[++i]!);
112
+ else if (a === '--l2-budget') opts.l2Budget = parseInt(args[++i]!);
113
+ else if (a === '--l3-budget') opts.l3Budget = parseInt(args[++i]!);
114
+ else if (a === '--merge-threshold') opts.mergeThreshold = parseInt(args[++i]!);
115
+ else {
116
+ console.error(`Unknown arg: ${a}`);
117
+ process.exit(1);
118
+ }
119
+ }
120
+ return opts;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Progress renderer
125
+ // ---------------------------------------------------------------------------
126
+
127
+ interface Spend {
128
+ inputTokens: number;
129
+ outputTokens: number;
130
+ cost: number;
131
+ }
132
+
133
+ function renderProgress(
134
+ strategy: AutobiographicalStrategy,
135
+ startedAt: number,
136
+ initialL1Queue: number,
137
+ spend: Spend,
138
+ isTty: boolean,
139
+ ): void {
140
+ const s = strategy.getProgressSnapshot();
141
+ const elapsedMs = Date.now() - startedAt;
142
+ const elapsedSec = elapsedMs / 1000;
143
+
144
+ // Throughput: L1 chunks processed per second since start
145
+ const l1Done = initialL1Queue - s.l1QueueLength;
146
+ const l1Pct = initialL1Queue > 0 ? (l1Done / initialL1Queue) * 100 : 100;
147
+ const throughput = l1Done > 0 ? l1Done / elapsedSec : 0;
148
+ const remainingL1 = s.l1QueueLength;
149
+ const etaSec = throughput > 0 ? remainingL1 / throughput : 0;
150
+
151
+ const phase = s.l1QueueLength > 0 ? 'L1' : s.mergeQueueLength > 0 ? 'merges' : 'done';
152
+ const line =
153
+ `[${phase}] L1 ${l1Done}/${initialL1Queue} (${l1Pct.toFixed(1)}%) │ ` +
154
+ `merges ${s.mergeQueueLength} queued │ ` +
155
+ `L1/L2/L3 ${s.summaryCounts.l1}/${s.summaryCounts.l2}/${s.summaryCounts.l3} │ ` +
156
+ `tok ${(spend.inputTokens / 1000).toFixed(0)}k in / ${(spend.outputTokens / 1000).toFixed(0)}k out │ ` +
157
+ `$${spend.cost.toFixed(2)} │ ` +
158
+ `${formatDuration(elapsedSec)} elapsed │ ` +
159
+ `ETA ${formatDuration(etaSec)}`;
160
+
161
+ if (isTty) {
162
+ process.stderr.write(`\r\x1b[2K${line}`);
163
+ } else {
164
+ process.stderr.write(`${line}\n`);
165
+ }
166
+ }
167
+
168
+ function formatDuration(sec: number): string {
169
+ if (!isFinite(sec) || sec < 0) return '?';
170
+ const h = Math.floor(sec / 3600);
171
+ const m = Math.floor((sec % 3600) / 60);
172
+ const s = Math.floor(sec % 60);
173
+ if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`;
174
+ if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`;
175
+ return `${s}s`;
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Main
180
+ // ---------------------------------------------------------------------------
181
+
182
+ async function main() {
183
+ const opts = parseArgs(process.argv);
184
+ if (!process.env.ANTHROPIC_API_KEY) {
185
+ console.error('Set ANTHROPIC_API_KEY');
186
+ process.exit(1);
187
+ }
188
+
189
+ const sessionMgr = new SessionManager(opts.dataDir);
190
+ const session = sessionMgr.findSession(opts.sessionRef);
191
+ if (!session) {
192
+ console.error(`No session matching "${opts.sessionRef}" in ${opts.dataDir}`);
193
+ process.exit(1);
194
+ }
195
+ const storePath = sessionMgr.getStorePath(session.id);
196
+
197
+ const importSource = sessionMgr.getImportSource(session.id);
198
+ const resolved = resolveAgentName({
199
+ explicit: opts.agentName,
200
+ sidecar: importSource?.agentName,
201
+ default: 'Claude',
202
+ });
203
+
204
+ console.error(`Warming up session "${session.name}" (${session.id})`);
205
+ console.error(`Store: ${storePath}`);
206
+ console.error(`Agent: ${resolved.name} (${resolved.source})`);
207
+ if (resolved.mismatch) {
208
+ console.error(
209
+ `WARNING: --agent "${resolved.mismatch.explicit}" overrides sidecar ` +
210
+ `"${resolved.mismatch.sidecar}". Any summaries previously written under ` +
211
+ `the sidecar name will be orphaned at agents/${resolved.mismatch.sidecar}/...`,
212
+ );
213
+ }
214
+ const agentName = resolved.name;
215
+ console.error(`Model: ${opts.model}`);
216
+ if (opts.maxSpend !== null) console.error(`Max spend: $${opts.maxSpend.toFixed(2)}`);
217
+
218
+ // -- Membrane with token-spend hook --
219
+ const price = priceOf(opts.model);
220
+ const spend: Spend = { inputTokens: 0, outputTokens: 0, cost: 0 };
221
+ const adapter = new AnthropicAdapter({ apiKey: process.env.ANTHROPIC_API_KEY });
222
+ const membrane = new Membrane(adapter, {
223
+ // Imported sessions store assistant turns under participant `agentName`.
224
+ // Membrane's default assistantParticipant is 'Claude'; if that disagrees
225
+ // with the stored participant every assistant message maps to role
226
+ // 'user' and the API rejects tool_use blocks.
227
+ assistantParticipant: agentName,
228
+ hooks: {
229
+ afterResponse: (response: NormalizedResponse) => {
230
+ const u = response.usage;
231
+ if (u) {
232
+ spend.inputTokens += u.inputTokens ?? 0;
233
+ spend.outputTokens += u.outputTokens ?? 0;
234
+ spend.cost =
235
+ (spend.inputTokens * price.input + spend.outputTokens * price.output) / 1_000_000;
236
+ }
237
+ return response;
238
+ },
239
+ },
240
+ });
241
+
242
+ // -- Store + strategy + context manager --
243
+ const store = JsStore.openOrCreate({ path: storePath });
244
+ const strategy = new AutobiographicalStrategy({
245
+ compressionModel: opts.model,
246
+ // Autobio uses summaryParticipant for the synthetic summary messages it
247
+ // writes back; aligning with the imported agent name keeps the whole
248
+ // conversation under a single participant.
249
+ summaryParticipant: agentName,
250
+ autoTickOnNewMessage: false, // we drive ticks manually
251
+ ...(opts.l1Budget !== undefined && { l1BudgetTokens: opts.l1Budget }),
252
+ ...(opts.l2Budget !== undefined && { l2BudgetTokens: opts.l2Budget }),
253
+ ...(opts.l3Budget !== undefined && { l3BudgetTokens: opts.l3Budget }),
254
+ ...(opts.mergeThreshold !== undefined && { mergeThreshold: opts.mergeThreshold }),
255
+ });
256
+
257
+ // Match AgentFramework.createAgent's namespace (`agents/${config.name}`)
258
+ // so warmup writes summaries where the live agent reads them.
259
+ const cm = await ContextManager.open({
260
+ store,
261
+ strategy,
262
+ membrane,
263
+ namespace: `agents/${agentName}`,
264
+ });
265
+
266
+ // -- Inspect initial state --
267
+ const initialStats = strategy.getProgressSnapshot();
268
+ const totalMessages = cm.getAllMessages().length;
269
+ console.error(
270
+ `\nInitial state: ${totalMessages} messages, ${initialStats.totalChunks} chunks total ` +
271
+ `(${initialStats.chunksCompressed} already compressed, ${initialStats.l1QueueLength} pending L1, ` +
272
+ `${initialStats.mergeQueueLength} merges pending).`,
273
+ );
274
+ if (initialStats.l1QueueLength === 0 && initialStats.mergeQueueLength === 0) {
275
+ console.error('Nothing to do — already converged.\n');
276
+ store.close?.();
277
+ return;
278
+ }
279
+ console.error('');
280
+
281
+ // -- Drive to convergence --
282
+ const isTty = process.stderr.isTTY ?? false;
283
+ const startedAt = Date.now();
284
+ const initialL1Queue = initialStats.l1QueueLength;
285
+
286
+ // Periodic progress refresh (handles long Sonnet calls so the bar doesn't
287
+ // freeze between ticks).
288
+ const renderTimer = setInterval(
289
+ () => renderProgress(strategy, startedAt, initialL1Queue, spend, isTty),
290
+ isTty ? 1000 : 30_000,
291
+ );
292
+
293
+ let aborted = false;
294
+ try {
295
+ while (true) {
296
+ const stats = strategy.getProgressSnapshot();
297
+ if (stats.l1QueueLength === 0 && stats.mergeQueueLength === 0) break;
298
+ if (opts.maxSpend !== null && spend.cost >= opts.maxSpend) {
299
+ aborted = true;
300
+ break;
301
+ }
302
+ await cm.tick();
303
+ }
304
+ } finally {
305
+ clearInterval(renderTimer);
306
+ renderProgress(strategy, startedAt, initialL1Queue, spend, isTty);
307
+ if (isTty) process.stderr.write('\n');
308
+ }
309
+
310
+ const finalStats = strategy.getProgressSnapshot();
311
+ console.error('');
312
+ if (aborted) {
313
+ console.error(
314
+ `Halted at $${spend.cost.toFixed(2)} (--max-spend $${opts.maxSpend?.toFixed(2)}). ` +
315
+ `Re-run to resume — autobio state persists in Chronicle.`,
316
+ );
317
+ } else if (finalStats.l1QueueLength === 0 && finalStats.mergeQueueLength === 0) {
318
+ console.error(
319
+ `Converged. ${finalStats.summaryCounts.l1} L1 + ${finalStats.summaryCounts.l2} L2 + ${finalStats.summaryCounts.l3} L3 summaries. ` +
320
+ `Total: ${spend.inputTokens.toLocaleString()} in / ${spend.outputTokens.toLocaleString()} out, $${spend.cost.toFixed(2)}.`,
321
+ );
322
+ } else {
323
+ console.error(
324
+ `Stopped with work remaining (L1=${finalStats.l1QueueLength}, merges=${finalStats.mergeQueueLength}). Re-run to continue.`,
325
+ );
326
+ }
327
+
328
+ store.close?.();
329
+ }
330
+
331
+ if (import.meta.main) {
332
+ main().catch((e) => {
333
+ console.error('\n', e);
334
+ process.exit(1);
335
+ });
336
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Resolve which participant name an agent should use when several sources
3
+ * (CLI flag, import-source sidecar, hard-coded default) disagree.
4
+ *
5
+ * Centralising this priority is load-bearing: warmup and conhost agree on
6
+ * the name → strategy state lives at the same Chronicle namespace → the
7
+ * live agent reads what warmup wrote. The original bug
8
+ * (`agents/agent/autobio:summaries` empty vs `default/autobio:summaries`
9
+ * populated) was caused by exactly this resolution drifting between two
10
+ * scripts that defaulted differently and never compared notes.
11
+ */
12
+
13
+ /** Which input the chosen name came from, for log lines and warnings. */
14
+ export type AgentNameSource = 'explicit' | 'sidecar' | 'default';
15
+
16
+ export interface ResolvedAgentName {
17
+ name: string;
18
+ source: AgentNameSource;
19
+ /**
20
+ * Set when both `explicit` and `sidecar` were provided and disagree.
21
+ * Callers should warn on this — the agent will use `explicit` (because
22
+ * an operator override beats a stored record) but the disagreement
23
+ * means some other consumer (e.g. warmup that wrote summaries under
24
+ * `sidecar`) has produced artifacts the live agent can't reach.
25
+ */
26
+ mismatch?: { explicit: string; sidecar: string };
27
+ }
28
+
29
+ /**
30
+ * Priority: `explicit` (CLI flag or non-default recipe field) > `sidecar`
31
+ * (per-session record written by the importer) > `default` (caller's
32
+ * context-specific fallback — `"Claude"` for the claudeai-revival
33
+ * scripts, `"agent"` for native conhost sessions).
34
+ *
35
+ * Empty strings on `explicit` or `sidecar` are treated as absent so an
36
+ * accidental `--agent ""` or a sidecar with `"agentName": ""` doesn't
37
+ * silently override the chain.
38
+ */
39
+ export function resolveAgentName(inputs: {
40
+ explicit?: string;
41
+ sidecar?: string;
42
+ default: string;
43
+ }): ResolvedAgentName {
44
+ const explicit = nonEmpty(inputs.explicit);
45
+ const sidecar = nonEmpty(inputs.sidecar);
46
+
47
+ const mismatch = explicit && sidecar && explicit !== sidecar
48
+ ? { explicit, sidecar }
49
+ : undefined;
50
+
51
+ if (explicit) return { name: explicit, source: 'explicit', ...(mismatch && { mismatch }) };
52
+ if (sidecar) return { name: sidecar, source: 'sidecar' };
53
+ return { name: inputs.default, source: 'default' };
54
+ }
55
+
56
+ function nonEmpty(s: string | undefined): string | undefined {
57
+ return typeof s === 'string' && s.length > 0 ? s : undefined;
58
+ }