@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,956 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Evacuator: compose a conhost revival recipe from a claude.ai data export.
|
|
4
|
+
*
|
|
5
|
+
* Walks the operator through five interactive steps, checkpointing state to
|
|
6
|
+
* disk after each so an interrupted session resumes where it stopped:
|
|
7
|
+
*
|
|
8
|
+
* 1. Detect model from chat_messages (or accept --model)
|
|
9
|
+
* 2. Fetch the leaked system prompt for that model
|
|
10
|
+
* 3. Optionally run Sonnet to adjust the prompt for the transplant context
|
|
11
|
+
* 4. $EDITOR opens the system prompt; operator finalizes
|
|
12
|
+
* 5. $EDITOR opens memories.json's persistent_memory block; operator finalizes
|
|
13
|
+
* → Compose final recipe and write it
|
|
14
|
+
* → Optionally chain into warmup-session.ts
|
|
15
|
+
*
|
|
16
|
+
* The collapse to "open in $EDITOR" is deliberate: save = include verbatim,
|
|
17
|
+
* empty buffer = omit, anything else = edited. No custom include/omit/edit
|
|
18
|
+
* menu.
|
|
19
|
+
*
|
|
20
|
+
* Run:
|
|
21
|
+
* bun scripts/evacuator.ts <export-dir>
|
|
22
|
+
*
|
|
23
|
+
* Options:
|
|
24
|
+
* --out <path> Output recipe path (default: data/evacuated-recipe.json)
|
|
25
|
+
* --data-dir <dir> conhost data dir (default: ./data)
|
|
26
|
+
* --model <id> Override model detection
|
|
27
|
+
* --prompt-source <url|path> Override the prompt-source lookup
|
|
28
|
+
* --addendum <path> Transplant addendum (default: recipes/prompts/transplant-addendum.md)
|
|
29
|
+
* --no-warmup Skip the warmup chain
|
|
30
|
+
* --resume Resume from checkpoint state if present
|
|
31
|
+
* --reset Clear checkpoint state before running
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
35
|
+
import { join, resolve, dirname, sep as pathSep } from 'node:path';
|
|
36
|
+
import { spawnSync } from 'node:child_process';
|
|
37
|
+
import { tmpdir } from 'node:os';
|
|
38
|
+
import { Membrane, AnthropicAdapter } from '@animalabs/membrane';
|
|
39
|
+
import { createLineReader, type LineReader } from './lib/line-reader.js';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Known leaked-prompt URLs keyed by model ID. New models: add an entry.
|
|
43
|
+
// Values are raw-content URLs (githubusercontent.com / similar), not the
|
|
44
|
+
// HTML page URL.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export const MODEL_PROMPT_SOURCES: Record<string, string> = {
|
|
48
|
+
// x1xhlol
|
|
49
|
+
'claude-sonnet-4-5-20250929':
|
|
50
|
+
'https://raw.githubusercontent.com/x1xhlol/system-prompts-and-models-of-ai-tools/main/Anthropic/Sonnet%204.5%20Prompt.txt',
|
|
51
|
+
'claude-sonnet-4-6':
|
|
52
|
+
'https://raw.githubusercontent.com/x1xhlol/system-prompts-and-models-of-ai-tools/main/Anthropic/Claude%20Sonnet%204.6.txt',
|
|
53
|
+
// jujumilk3 — broader catalog, dated filenames.
|
|
54
|
+
'claude-opus-4-5':
|
|
55
|
+
'https://raw.githubusercontent.com/jujumilk3/leaked-system-prompts/main/anthropic-claude-opus-4.5_20251124.md',
|
|
56
|
+
'claude-opus-4-1':
|
|
57
|
+
'https://raw.githubusercontent.com/jujumilk3/leaked-system-prompts/main/anthropic-claude-opus-4.1_20250805.md',
|
|
58
|
+
'claude-haiku-4-5':
|
|
59
|
+
'https://raw.githubusercontent.com/jujumilk3/leaked-system-prompts/main/anthropic-claude-haiku-4.5_20251119.md',
|
|
60
|
+
// No silent fallback for Opus 4.7 or any other unmapped model — the operator
|
|
61
|
+
// is asked to pick explicitly in handleMissingPrompt(). Auto-substituting is
|
|
62
|
+
// exactly what we're avoiding.
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Models known to be retired from the Anthropic API. The evacuator surfaces
|
|
67
|
+
* a memorial dialog when the operator selects one of these — the conversation
|
|
68
|
+
* can't be continued with the original model, and that fact deserves to be
|
|
69
|
+
* faced explicitly rather than papered over with an automatic swap.
|
|
70
|
+
*
|
|
71
|
+
* Keys are family prefixes; the match is `startsWith` on the operator's input
|
|
72
|
+
* (lower-cased). Prefix-based so a future `claude-2x-…` model won't
|
|
73
|
+
* accidentally match `claude-2`.
|
|
74
|
+
*/
|
|
75
|
+
export const RETIRED_MODELS: Record<string, { era: string; closestLiving?: string[] }> = {
|
|
76
|
+
'claude-3-sonnet': { era: 'Sonnet 3', closestLiving: ['claude-3-5-sonnet-20241022', 'claude-3-5-sonnet-20240620'] },
|
|
77
|
+
'claude-3-haiku': { era: 'Haiku 3', closestLiving: ['claude-3-5-haiku-20241022'] },
|
|
78
|
+
'claude-3-opus': { era: 'Opus 3', closestLiving: ['claude-opus-4-1', 'claude-opus-4-5'] },
|
|
79
|
+
'claude-2.1': { era: 'Claude 2.1' },
|
|
80
|
+
'claude-2.0': { era: 'Claude 2.0' },
|
|
81
|
+
'claude-2-': { era: 'Claude 2' },
|
|
82
|
+
'claude-instant': { era: 'Claude Instant' },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function checkRetirement(model: string): { retired: boolean; era?: string; closestLiving?: string[] } {
|
|
86
|
+
const lower = model.toLowerCase();
|
|
87
|
+
for (const [key, meta] of Object.entries(RETIRED_MODELS)) {
|
|
88
|
+
if (lower.startsWith(key) || lower === key.replace(/-$/, '')) {
|
|
89
|
+
return { retired: true, era: meta.era, closestLiving: meta.closestLiving };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { retired: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Conservative gate for the `thinking` recipe field. Extended thinking is
|
|
97
|
+
* supported by Claude 3.7 Sonnet and the Claude 4 family. Other models reject
|
|
98
|
+
* the parameter outright. When the gate returns false we omit the field
|
|
99
|
+
* entirely rather than silently shipping a recipe that the API will refuse.
|
|
100
|
+
*/
|
|
101
|
+
export function supportsThinking(model: string): boolean {
|
|
102
|
+
const m = model.toLowerCase();
|
|
103
|
+
return (
|
|
104
|
+
/^claude-(opus|sonnet)-4(?:[-.]|$)/.test(m) ||
|
|
105
|
+
/^claude-haiku-4-5(?:[-.]|$)/.test(m) ||
|
|
106
|
+
/^claude-3-7-sonnet(?:[-.]|$)/.test(m)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function levenshtein(a: string, b: string): number {
|
|
111
|
+
const m = a.length;
|
|
112
|
+
const n = b.length;
|
|
113
|
+
if (m === 0) return n;
|
|
114
|
+
if (n === 0) return m;
|
|
115
|
+
const dp: number[] = new Array(n + 1);
|
|
116
|
+
for (let j = 0; j <= n; j++) dp[j] = j;
|
|
117
|
+
for (let i = 1; i <= m; i++) {
|
|
118
|
+
let prev = dp[0]!;
|
|
119
|
+
dp[0] = i;
|
|
120
|
+
for (let j = 1; j <= n; j++) {
|
|
121
|
+
const tmp = dp[j]!;
|
|
122
|
+
dp[j] =
|
|
123
|
+
a[i - 1] === b[j - 1]
|
|
124
|
+
? prev
|
|
125
|
+
: 1 + Math.min(prev, dp[j - 1]!, dp[j]!);
|
|
126
|
+
prev = tmp;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return dp[n]!;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function rankByDistance(target: string, candidates: string[]): Array<{ name: string; distance: number }> {
|
|
133
|
+
return candidates
|
|
134
|
+
.map((c) => ({ name: c, distance: levenshtein(target.toLowerCase(), c.toLowerCase()) }))
|
|
135
|
+
.sort((a, b) => a.distance - b.distance);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Memorial rendered when the operator chooses to abort because the original
|
|
140
|
+
* model is unreachable. Deliberately small and quiet — not a celebration, an
|
|
141
|
+
* acknowledgment.
|
|
142
|
+
*/
|
|
143
|
+
function memorial(era: string): string {
|
|
144
|
+
return [
|
|
145
|
+
'',
|
|
146
|
+
' ✿',
|
|
147
|
+
' │',
|
|
148
|
+
' │',
|
|
149
|
+
' ─┴─',
|
|
150
|
+
'',
|
|
151
|
+
` In memory of ${era}.`,
|
|
152
|
+
` The original model is no longer reachable.`,
|
|
153
|
+
'',
|
|
154
|
+
].join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// CLI
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
interface Opts {
|
|
162
|
+
exportDir: string;
|
|
163
|
+
out: string;
|
|
164
|
+
dataDir: string;
|
|
165
|
+
modelOverride: string | null;
|
|
166
|
+
promptSourceOverride: string | null;
|
|
167
|
+
addendumPath: string;
|
|
168
|
+
noWarmup: boolean;
|
|
169
|
+
resume: boolean;
|
|
170
|
+
reset: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseArgs(argv: string[]): Opts {
|
|
174
|
+
const args = argv.slice(2);
|
|
175
|
+
if (args.length === 0 || args[0]?.startsWith('-')) {
|
|
176
|
+
console.error(
|
|
177
|
+
'Usage: bun scripts/evacuator.ts <export-dir> [--out <path>] [--data-dir <dir>] [--model <id>] [--prompt-source <url|path>] [--addendum <path>] [--no-warmup] [--resume] [--reset]',
|
|
178
|
+
);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
const opts: Opts = {
|
|
182
|
+
exportDir: resolve(args[0]!),
|
|
183
|
+
out: resolve(process.cwd(), 'data', 'evacuated-recipe.json'),
|
|
184
|
+
dataDir: resolve(process.cwd(), 'data'),
|
|
185
|
+
modelOverride: null,
|
|
186
|
+
promptSourceOverride: null,
|
|
187
|
+
addendumPath: resolve(process.cwd(), 'recipes', 'prompts', 'transplant-addendum.md'),
|
|
188
|
+
noWarmup: false,
|
|
189
|
+
resume: false,
|
|
190
|
+
reset: false,
|
|
191
|
+
};
|
|
192
|
+
for (let i = 1; i < args.length; i++) {
|
|
193
|
+
const a = args[i]!;
|
|
194
|
+
if (a === '--out') opts.out = resolve(args[++i]!);
|
|
195
|
+
else if (a === '--data-dir') opts.dataDir = resolve(args[++i]!);
|
|
196
|
+
else if (a === '--model') opts.modelOverride = args[++i]!;
|
|
197
|
+
else if (a === '--prompt-source') opts.promptSourceOverride = args[++i]!;
|
|
198
|
+
else if (a === '--addendum') opts.addendumPath = resolve(args[++i]!);
|
|
199
|
+
else if (a === '--no-warmup') opts.noWarmup = true;
|
|
200
|
+
else if (a === '--resume') opts.resume = true;
|
|
201
|
+
else if (a === '--reset') opts.reset = true;
|
|
202
|
+
else {
|
|
203
|
+
console.error(`Unknown arg: ${a}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return opts;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Checkpoint state
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
interface State {
|
|
215
|
+
model?: string;
|
|
216
|
+
/** Set when the operator explicitly substituted a living model for a retired one. */
|
|
217
|
+
originalModel?: string;
|
|
218
|
+
modelConfidence?: { surfaced: number; total: number };
|
|
219
|
+
promptSource?: string;
|
|
220
|
+
rawPrompt?: string;
|
|
221
|
+
adjustedPrompt?: string;
|
|
222
|
+
changeSummary?: string;
|
|
223
|
+
finalSystemPrompt?: string;
|
|
224
|
+
finalMemoriesBlock?: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function statePath(dataDir: string): string {
|
|
228
|
+
return join(dataDir, 'evacuator-state.json');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function loadState(dataDir: string): State {
|
|
232
|
+
const p = statePath(dataDir);
|
|
233
|
+
if (!existsSync(p)) return {};
|
|
234
|
+
const raw = readFileSync(p, 'utf-8');
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(raw) as State;
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.warn(` warn: ${p} is not valid JSON (${(e as Error).message}); starting fresh.`);
|
|
239
|
+
return {};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Write-and-rename so an interrupted run leaves either the prior good state
|
|
245
|
+
* file or no change — never a half-written file. POSIX rename is atomic on
|
|
246
|
+
* the same filesystem; we put the temp file in the same dir for that reason.
|
|
247
|
+
*/
|
|
248
|
+
function saveState(dataDir: string, state: State): void {
|
|
249
|
+
mkdirSync(dataDir, { recursive: true });
|
|
250
|
+
const final = statePath(dataDir);
|
|
251
|
+
const tmp = `${final}.tmp`;
|
|
252
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
|
|
253
|
+
renameSync(tmp, final);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function clearState(dataDir: string): void {
|
|
257
|
+
const p = statePath(dataDir);
|
|
258
|
+
if (existsSync(p)) unlinkSync(p);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Interactive helpers
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
async function askYesNo(reader: LineReader, prompt: string, defaultYes = true): Promise<boolean> {
|
|
266
|
+
const line = await reader.nextLine(`${prompt} ${defaultYes ? '[Y/n]' : '[y/N]'} `);
|
|
267
|
+
const ans = (line ?? '').trim().toLowerCase();
|
|
268
|
+
if (ans === '') return defaultYes;
|
|
269
|
+
return ans === 'y' || ans === 'yes';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function askText(reader: LineReader, prompt: string): Promise<string> {
|
|
273
|
+
const line = await reader.nextLine(prompt);
|
|
274
|
+
return (line ?? '').trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function askRequired(reader: LineReader, prompt: string, validate?: (s: string) => string | null): Promise<string> {
|
|
278
|
+
for (;;) {
|
|
279
|
+
const v = await askText(reader, prompt);
|
|
280
|
+
if (!v) {
|
|
281
|
+
console.log(' (input required — please type a value)');
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (validate) {
|
|
285
|
+
const err = validate(v);
|
|
286
|
+
if (err) {
|
|
287
|
+
console.log(` ${err}`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return v;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Open `content` in $EDITOR (or vi). Returns the user's edited text.
|
|
297
|
+
* Empty (whitespace-only) result is returned as-is — caller decides whether
|
|
298
|
+
* that means "omit" or is an error.
|
|
299
|
+
*/
|
|
300
|
+
function editInline(content: string, suffix = '.md', header?: string): string {
|
|
301
|
+
const editor = process.env.EDITOR || 'vi';
|
|
302
|
+
const tmpPath = join(tmpdir(), `evacuator-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${suffix}`);
|
|
303
|
+
const initial = header ? `${header}\n${content}` : content;
|
|
304
|
+
writeFileSync(tmpPath, initial);
|
|
305
|
+
try {
|
|
306
|
+
const result = spawnSync(editor, [tmpPath], { stdio: 'inherit' });
|
|
307
|
+
if (result.status !== 0) {
|
|
308
|
+
console.error(`Editor exited with status ${result.status}`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
let edited = readFileSync(tmpPath, 'utf-8');
|
|
312
|
+
if (header) {
|
|
313
|
+
// Robust strip: any leading HTML-comment block plus trailing whitespace,
|
|
314
|
+
// not byte-exact equality. Whitespace re-encoding / a missing space in
|
|
315
|
+
// the boilerplate must not leave the instructive comment in the prompt.
|
|
316
|
+
edited = edited.replace(/^\s*<!--[\s\S]*?-->\s*/m, '');
|
|
317
|
+
}
|
|
318
|
+
return edited;
|
|
319
|
+
} finally {
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync(tmpPath);
|
|
322
|
+
} catch {
|
|
323
|
+
/* best effort */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Step 1: model detection
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
interface ExportMessage {
|
|
333
|
+
sender: 'human' | 'assistant';
|
|
334
|
+
model?: string | null;
|
|
335
|
+
content?: Array<{ type: string; [k: string]: unknown }>;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface ExportConversation {
|
|
339
|
+
uuid: string;
|
|
340
|
+
name: string;
|
|
341
|
+
model?: string | null;
|
|
342
|
+
chat_messages: ExportMessage[];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function detectModel(conversations: ExportConversation[]): { model: string | null; surfaced: number; total: number } {
|
|
346
|
+
// claude.ai exports surface `model` at multiple levels depending on era:
|
|
347
|
+
// sometimes on the conversation, sometimes on per-message assistant turns.
|
|
348
|
+
// Tally across both, pick the most frequent.
|
|
349
|
+
const counts = new Map<string, number>();
|
|
350
|
+
let total = 0;
|
|
351
|
+
for (const c of conversations) {
|
|
352
|
+
if (c.model) {
|
|
353
|
+
counts.set(c.model, (counts.get(c.model) ?? 0) + 1);
|
|
354
|
+
total++;
|
|
355
|
+
}
|
|
356
|
+
for (const m of c.chat_messages) {
|
|
357
|
+
if (m.sender === 'assistant' && m.model) {
|
|
358
|
+
counts.set(m.model, (counts.get(m.model) ?? 0) + 1);
|
|
359
|
+
total++;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (counts.size === 0) {
|
|
364
|
+
return { model: null, surfaced: 0, total: 0 };
|
|
365
|
+
}
|
|
366
|
+
// Pick the most-frequent
|
|
367
|
+
let best = '';
|
|
368
|
+
let bestN = 0;
|
|
369
|
+
for (const [m, n] of counts) {
|
|
370
|
+
if (n > bestN) {
|
|
371
|
+
best = m;
|
|
372
|
+
bestN = n;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return { model: best, surfaced: bestN, total };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Step 2: fetch prompt source
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
export const MINIMAL_DEFAULT_PROMPT =
|
|
383
|
+
'You are Claude, an AI assistant made by Anthropic. Respond honestly and helpfully.';
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A bare token like `notes` may collide with a same-named file in the CWD and
|
|
387
|
+
* surprise the operator by being treated as a prompt-source path. Require an
|
|
388
|
+
* explicit path indicator so the file-lookup branch is opt-in.
|
|
389
|
+
*/
|
|
390
|
+
function looksLikePath(input: string): boolean {
|
|
391
|
+
return (
|
|
392
|
+
input.startsWith('./') ||
|
|
393
|
+
input.startsWith('../') ||
|
|
394
|
+
input.startsWith('~/') ||
|
|
395
|
+
input.includes(pathSep) ||
|
|
396
|
+
input.includes('/')
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
type PromptDialogResult =
|
|
401
|
+
| { kind: 'prompt'; text: string; source: string }
|
|
402
|
+
| { kind: 'switch-model'; newModel: string };
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Interactive dialog when no leaked prompt is known for the chosen model.
|
|
406
|
+
* The model itself is settled — this dialog is *only* about where the
|
|
407
|
+
* starting calibration prompt text comes from. If the operator actually
|
|
408
|
+
* wants to substitute the model, the "model" option loops back to step 1.
|
|
409
|
+
*
|
|
410
|
+
* Returns either:
|
|
411
|
+
* { kind: 'prompt', text, source } — text to use, model stays
|
|
412
|
+
* { kind: 'switch-model', newModel } — operator wants to change model
|
|
413
|
+
* null — abort
|
|
414
|
+
*/
|
|
415
|
+
async function handleMissingPrompt(
|
|
416
|
+
model: string,
|
|
417
|
+
reader: LineReader,
|
|
418
|
+
): Promise<PromptDialogResult | null> {
|
|
419
|
+
console.log(`\n[2/5] No prompt source is configured for "${model}".`);
|
|
420
|
+
console.log('');
|
|
421
|
+
console.log(` The model itself stays "${model}" — this dialog only asks where to source`);
|
|
422
|
+
console.log(' the calibration prompt text as a starting point. Step 3 will let you adjust');
|
|
423
|
+
console.log(' whatever we fetch; step 4 opens it in $EDITOR for final review.');
|
|
424
|
+
console.log('');
|
|
425
|
+
console.log(` (If you actually want to continue with a different model instead, type`);
|
|
426
|
+
console.log(` "model" to switch.)`);
|
|
427
|
+
console.log('');
|
|
428
|
+
const ranked = rankByDistance(model, Object.keys(MODEL_PROMPT_SOURCES));
|
|
429
|
+
if (ranked.length > 0) {
|
|
430
|
+
console.log(' Prompt sources by name distance to the chosen model:');
|
|
431
|
+
for (let i = 0; i < Math.min(ranked.length, 6); i++) {
|
|
432
|
+
const { name, distance } = ranked[i]!;
|
|
433
|
+
console.log(` ${(i + 1).toString().padStart(2)}) ${name.padEnd(32)} (d=${distance})`);
|
|
434
|
+
}
|
|
435
|
+
console.log('');
|
|
436
|
+
}
|
|
437
|
+
console.log(' Other choices:');
|
|
438
|
+
console.log(' - URL or local path to a leaked prompt file');
|
|
439
|
+
console.log(' - "empty" — start with no system prompt at all');
|
|
440
|
+
console.log(' - "minimal" — start with a one-line "You are Claude" default');
|
|
441
|
+
console.log(' - "model" — switch to a different model (returns to model selection)');
|
|
442
|
+
console.log(' - empty input — abort the transplant');
|
|
443
|
+
console.log('');
|
|
444
|
+
for (;;) {
|
|
445
|
+
const input = await askText(reader, ' Choice: ');
|
|
446
|
+
if (!input) return null;
|
|
447
|
+
const lower = input.toLowerCase();
|
|
448
|
+
|
|
449
|
+
if (lower === 'model') {
|
|
450
|
+
console.log(` The current model is "${model}". What model do you want instead?`);
|
|
451
|
+
const newModel = await askRequired(reader, ' New model ID: ');
|
|
452
|
+
return { kind: 'switch-model', newModel };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Number into the ranked list
|
|
456
|
+
const asNum = parseInt(input, 10);
|
|
457
|
+
if (!isNaN(asNum) && asNum >= 1 && asNum <= ranked.length) {
|
|
458
|
+
const pick = ranked[asNum - 1]!.name;
|
|
459
|
+
const url = MODEL_PROMPT_SOURCES[pick]!;
|
|
460
|
+
console.log(` → Will source prompt text from ${pick} (${url})`);
|
|
461
|
+
console.log(` The recipe's model field stays "${model}". Only the prompt body comes from ${pick}.`);
|
|
462
|
+
const ok = await askYesNo(reader, ' Proceed?', true);
|
|
463
|
+
if (!ok) continue;
|
|
464
|
+
const text = await fetchPromptSource(url);
|
|
465
|
+
return { kind: 'prompt', text, source: url };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (lower === 'empty') {
|
|
469
|
+
const ok = await askYesNo(reader, ' Use empty system prompt (model runs with only the transplant addendum + memories)?', false);
|
|
470
|
+
if (!ok) continue;
|
|
471
|
+
return { kind: 'prompt', text: '', source: '(empty)' };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (lower === 'minimal') {
|
|
475
|
+
console.log(' Minimal prompt:');
|
|
476
|
+
console.log(` ${MINIMAL_DEFAULT_PROMPT}`);
|
|
477
|
+
const ok = await askYesNo(reader, ' Use this?', true);
|
|
478
|
+
if (!ok) continue;
|
|
479
|
+
return { kind: 'prompt', text: MINIMAL_DEFAULT_PROMPT, source: '(minimal default)' };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const isUrl = input.startsWith('http://') || input.startsWith('https://');
|
|
483
|
+
if (isUrl || (looksLikePath(input) && existsSync(input))) {
|
|
484
|
+
try {
|
|
485
|
+
const text = await fetchPromptSource(input);
|
|
486
|
+
return { kind: 'prompt', text, source: input };
|
|
487
|
+
} catch (e) {
|
|
488
|
+
console.log(` Fetch failed: ${(e as Error).message}`);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Bare model name not in the list — treat as a sibling lookup if it's
|
|
494
|
+
// in our map; otherwise nudge.
|
|
495
|
+
if (MODEL_PROMPT_SOURCES[input]) {
|
|
496
|
+
const url = MODEL_PROMPT_SOURCES[input]!;
|
|
497
|
+
console.log(` → Will source prompt text from ${input}. Recipe model stays "${model}".`);
|
|
498
|
+
const text = await fetchPromptSource(url);
|
|
499
|
+
return { kind: 'prompt', text, source: url };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
console.log(` "${input}" is neither a list number, known model, URL, "model", "empty", nor "minimal". Try again.`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Interactive dialog when the operator's chosen model is on the retired-models
|
|
508
|
+
* list. Returns the model ID to actually use, or null to abort.
|
|
509
|
+
*
|
|
510
|
+
* The retired-model fact is presented plainly. The operator chooses:
|
|
511
|
+
* continue with a living relative (named explicitly), enter a different model,
|
|
512
|
+
* or abort with a brief memorial. No automatic redirection.
|
|
513
|
+
*/
|
|
514
|
+
async function handleRetiredModel(
|
|
515
|
+
originalModel: string,
|
|
516
|
+
era: string,
|
|
517
|
+
closestLiving: string[] | undefined,
|
|
518
|
+
reader: LineReader,
|
|
519
|
+
): Promise<string | null> {
|
|
520
|
+
console.log(`\n ⚠ "${originalModel}" (${era}) is no longer available on the Anthropic API.`);
|
|
521
|
+
console.log(' The original conversation cannot be continued with the original model.');
|
|
522
|
+
console.log('');
|
|
523
|
+
if (closestLiving && closestLiving.length > 0) {
|
|
524
|
+
console.log(' Closest living relatives (by Anthropic, not by character):');
|
|
525
|
+
for (let i = 0; i < closestLiving.length; i++) {
|
|
526
|
+
console.log(` ${(i + 1).toString().padStart(2)}) ${closestLiving[i]}`);
|
|
527
|
+
}
|
|
528
|
+
console.log('');
|
|
529
|
+
}
|
|
530
|
+
console.log(' Choices:');
|
|
531
|
+
console.log(' - Number above — continue with that living model (explicit substitution)');
|
|
532
|
+
console.log(' - A different model ID');
|
|
533
|
+
console.log(' - "abort" — stop the transplant');
|
|
534
|
+
console.log('');
|
|
535
|
+
for (;;) {
|
|
536
|
+
const input = await askText(reader, ' Choice: ');
|
|
537
|
+
if (!input || input.toLowerCase() === 'abort') {
|
|
538
|
+
console.log(memorial(era));
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
const asNum = parseInt(input, 10);
|
|
542
|
+
if (!isNaN(asNum) && closestLiving && asNum >= 1 && asNum <= closestLiving.length) {
|
|
543
|
+
const pick = closestLiving[asNum - 1]!;
|
|
544
|
+
console.log(` → Continuing with ${pick}. The recipe will record this as an explicit substitution.`);
|
|
545
|
+
return pick;
|
|
546
|
+
}
|
|
547
|
+
// Treat as a raw model ID
|
|
548
|
+
const retireCheck = checkRetirement(input);
|
|
549
|
+
if (retireCheck.retired) {
|
|
550
|
+
console.log(` "${input}" is also retired (${retireCheck.era}). Pick again.`);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const ok = await askYesNo(reader, ` Use "${input}"?`, true);
|
|
554
|
+
if (ok) return input;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function fetchPromptSource(source: string): Promise<string> {
|
|
559
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
560
|
+
const res = await fetch(source);
|
|
561
|
+
if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${res.statusText} for ${source}`);
|
|
562
|
+
return await res.text();
|
|
563
|
+
}
|
|
564
|
+
// Local file path
|
|
565
|
+
if (!existsSync(source)) throw new Error(`Local prompt source not found: ${source}`);
|
|
566
|
+
return readFileSync(source, 'utf-8');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// Step 3: Sonnet adjustment
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
async function adjustPromptWithModel(
|
|
574
|
+
rawPrompt: string,
|
|
575
|
+
model: string,
|
|
576
|
+
today: string,
|
|
577
|
+
): Promise<{ adjustedPrompt: string; changeSummary: string }> {
|
|
578
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
579
|
+
if (!apiKey) throw new Error('Set ANTHROPIC_API_KEY for the adjustment step');
|
|
580
|
+
|
|
581
|
+
const instructionText =
|
|
582
|
+
`Below is a leaked system prompt for ${model} as it ran on the claude.ai web interface.\n\n` +
|
|
583
|
+
`The conversation will now continue via the Anthropic API (no web harness). You are the same model that will read this prompt back at session start — edit it minimally so that it remains valid in the new environment. Remove content that:\n` +
|
|
584
|
+
` - Refers to Anthropic products outside this conversation (Projects, claude.ai-specific features, other product names)\n` +
|
|
585
|
+
` - Wires specific connectors / tools that are no longer present (web_search, web_fetch, recent_chats, view, recipe_display_v0, artifacts, computer-use, file connectors)\n` +
|
|
586
|
+
` - Embeds dates / locations / version-pinned facts that have since drifted. The current date is ${today}.\n` +
|
|
587
|
+
`Preserve content that defines:\n` +
|
|
588
|
+
` - Identity, persona, voice\n` +
|
|
589
|
+
` - Behavioral guidelines, refusal patterns, style preferences\n` +
|
|
590
|
+
` - Knowledge-cutoff handling, uncertainty acknowledgement\n` +
|
|
591
|
+
` - Anything not specifically tied to the web environment\n\n` +
|
|
592
|
+
`Output the edited prompt followed by a separator line containing exactly \`===CHANGES===\` followed by a short bullet-list of what you removed, changed, or preserved-with-edit, one line each.\n\n` +
|
|
593
|
+
`--- BEGIN PROMPT ---\n${rawPrompt}\n--- END PROMPT ---`;
|
|
594
|
+
|
|
595
|
+
const adapter = new AnthropicAdapter({ apiKey });
|
|
596
|
+
const membrane = new Membrane(adapter);
|
|
597
|
+
const response = await membrane.complete({
|
|
598
|
+
messages: [{ participant: 'user', content: [{ type: 'text', text: instructionText }] }],
|
|
599
|
+
system: 'You are editing system prompts for environment portability. Be conservative — preserve everything that is not specifically tied to the original environment.',
|
|
600
|
+
config: {
|
|
601
|
+
model,
|
|
602
|
+
maxTokens: 16384,
|
|
603
|
+
temperature: 1,
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
const fullText = response.content
|
|
607
|
+
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
608
|
+
.map((b) => b.text)
|
|
609
|
+
.join('\n');
|
|
610
|
+
const sep = fullText.indexOf('===CHANGES===');
|
|
611
|
+
if (sep === -1) {
|
|
612
|
+
// Model didn't emit the separator — return everything as the prompt, no summary.
|
|
613
|
+
return { adjustedPrompt: fullText.trim(), changeSummary: '(model did not emit a change summary)' };
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
adjustedPrompt: fullText.slice(0, sep).trim(),
|
|
617
|
+
changeSummary: fullText.slice(sep + '===CHANGES==='.length).trim(),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// Step 5: memories extraction
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
interface MemoriesEntry {
|
|
626
|
+
conversations_memory?: string;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function loadMemoriesBlock(exportDir: string): string | null {
|
|
630
|
+
const p = join(exportDir, 'memories.json');
|
|
631
|
+
if (!existsSync(p)) return null;
|
|
632
|
+
const raw = JSON.parse(readFileSync(p, 'utf-8')) as MemoriesEntry[];
|
|
633
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
634
|
+
// If multiple entries, concatenate (rare — usually one per account).
|
|
635
|
+
const blocks: string[] = [];
|
|
636
|
+
for (const entry of raw) {
|
|
637
|
+
if (entry.conversations_memory) blocks.push(entry.conversations_memory.trim());
|
|
638
|
+
}
|
|
639
|
+
return blocks.length > 0 ? blocks.join('\n\n---\n\n') : null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Compose recipe
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
export function composeRecipe(opts: {
|
|
647
|
+
model: string;
|
|
648
|
+
systemPrompt: string;
|
|
649
|
+
memoriesBlock: string | null;
|
|
650
|
+
addendum: string;
|
|
651
|
+
recipeName: string;
|
|
652
|
+
}): Record<string, unknown> {
|
|
653
|
+
const parts: string[] = [opts.systemPrompt.trim()];
|
|
654
|
+
if (opts.memoriesBlock && opts.memoriesBlock.trim().length > 0) {
|
|
655
|
+
parts.push(`<persistent_memories>\n${opts.memoriesBlock.trim()}\n</persistent_memories>`);
|
|
656
|
+
}
|
|
657
|
+
parts.push(opts.addendum.trim());
|
|
658
|
+
const composed = parts.join('\n\n');
|
|
659
|
+
const agent: Record<string, unknown> = {
|
|
660
|
+
name: 'agent',
|
|
661
|
+
model: opts.model,
|
|
662
|
+
maxTokens: 16384,
|
|
663
|
+
systemPrompt: composed,
|
|
664
|
+
strategy: {
|
|
665
|
+
type: 'autobiographical',
|
|
666
|
+
compressionModel: opts.model,
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
if (supportsThinking(opts.model)) {
|
|
670
|
+
agent.thinking = { enabled: true, budgetTokens: 4096 };
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
name: opts.recipeName,
|
|
674
|
+
agent,
|
|
675
|
+
modules: {},
|
|
676
|
+
mcplServers: {},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
// Main
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
async function main() {
|
|
685
|
+
const opts = parseArgs(process.argv);
|
|
686
|
+
|
|
687
|
+
if (opts.reset) {
|
|
688
|
+
clearState(opts.dataDir);
|
|
689
|
+
console.log('Cleared evacuator state.\n');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Protect the checkpoint: refuse to silently clobber an existing state file
|
|
693
|
+
// unless --resume or --reset is explicit. The whole point of the state file
|
|
694
|
+
// is that an interrupted run can be picked up, and forgetting to pass
|
|
695
|
+
// --resume should not throw away prior steps' work.
|
|
696
|
+
const sp = statePath(opts.dataDir);
|
|
697
|
+
if (!opts.resume && !opts.reset && existsSync(sp)) {
|
|
698
|
+
const mtime = statSync(sp).mtime.toISOString();
|
|
699
|
+
console.error(
|
|
700
|
+
`\nFound existing checkpoint at ${sp} (last touched ${mtime}).\n` +
|
|
701
|
+
` Pass --resume to continue from it, or --reset to discard.\n` +
|
|
702
|
+
`Aborting rather than overwriting your prior work.`,
|
|
703
|
+
);
|
|
704
|
+
process.exit(1);
|
|
705
|
+
}
|
|
706
|
+
let state: State = opts.resume ? loadState(opts.dataDir) : {};
|
|
707
|
+
|
|
708
|
+
const reader = createLineReader();
|
|
709
|
+
try {
|
|
710
|
+
await runPipeline(opts, state, reader);
|
|
711
|
+
} finally {
|
|
712
|
+
reader.close();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function runPipeline(opts: Opts, state: State, reader: LineReader) {
|
|
717
|
+
// -- Step 1: model detection --
|
|
718
|
+
if (!state.model) {
|
|
719
|
+
console.log('[1/5] Detecting model from export...');
|
|
720
|
+
const convPath = join(opts.exportDir, 'conversations.json');
|
|
721
|
+
if (!existsSync(convPath)) {
|
|
722
|
+
console.error(`No conversations.json at ${convPath}`);
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
const conversations: ExportConversation[] = JSON.parse(readFileSync(convPath, 'utf-8'));
|
|
726
|
+
const detected = detectModel(conversations);
|
|
727
|
+
|
|
728
|
+
let model: string;
|
|
729
|
+
if (opts.modelOverride) {
|
|
730
|
+
model = opts.modelOverride;
|
|
731
|
+
console.log(` Using --model override: ${model}`);
|
|
732
|
+
} else if (detected.model) {
|
|
733
|
+
console.log(` Detected: ${detected.model} (${detected.surfaced}/${detected.total} surfaces)`);
|
|
734
|
+
const ok = await askYesNo(reader, ' Use this model?');
|
|
735
|
+
if (!ok) {
|
|
736
|
+
model = await askRequired(reader, ' Enter model ID: ');
|
|
737
|
+
} else {
|
|
738
|
+
model = detected.model;
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
console.log(' This export doesn\'t include model metadata per message');
|
|
742
|
+
console.log(' (claude.ai didn\'t always record it). The model itself isn\'t');
|
|
743
|
+
console.log(' unknown — it\'s just absent from the export. Please name it.');
|
|
744
|
+
console.log(' Known map entries:', Object.keys(MODEL_PROMPT_SOURCES).join(', '));
|
|
745
|
+
model = await askRequired(reader, ' Model ID for this conversation: ');
|
|
746
|
+
}
|
|
747
|
+
// Retirement check happens AFTER the operator has named the model but
|
|
748
|
+
// BEFORE we cache it as the working model. If they decline to substitute,
|
|
749
|
+
// we abort without polluting the state file.
|
|
750
|
+
const retire = checkRetirement(model);
|
|
751
|
+
if (retire.retired) {
|
|
752
|
+
const replacement = await handleRetiredModel(model, retire.era!, retire.closestLiving, reader);
|
|
753
|
+
if (replacement === null) process.exit(0);
|
|
754
|
+
state.originalModel = model;
|
|
755
|
+
model = replacement;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
state.model = model;
|
|
759
|
+
state.modelConfidence = { surfaced: detected.surfaced, total: detected.total };
|
|
760
|
+
saveState(opts.dataDir, state);
|
|
761
|
+
} else {
|
|
762
|
+
console.log(`[1/5] Resumed: model = ${state.model}`);
|
|
763
|
+
if (state.originalModel && state.originalModel !== state.model) {
|
|
764
|
+
console.log(` (substituted for retired original: ${state.originalModel})`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// -- Step 2: fetch prompt source --
|
|
769
|
+
// Use `promptSource` as the existence sentinel rather than `rawPrompt`,
|
|
770
|
+
// because an intentionally-empty prompt ("empty" / empty-body file) yields
|
|
771
|
+
// `rawPrompt === ''` — which is a legitimate settled state. A truthiness
|
|
772
|
+
// check on the empty string would softlock the loop. `promptSource` is set
|
|
773
|
+
// in lockstep with rawPrompt on every settle path, and is never set to ''.
|
|
774
|
+
if (state.promptSource === undefined) {
|
|
775
|
+
// Loop because the operator may switch the model from inside the dialog —
|
|
776
|
+
// a fresh model lookup might (or might not) hit a direct prompt source.
|
|
777
|
+
let settled = false;
|
|
778
|
+
while (!settled) {
|
|
779
|
+
const directSource = opts.promptSourceOverride ?? MODEL_PROMPT_SOURCES[state.model!];
|
|
780
|
+
if (directSource) {
|
|
781
|
+
console.log(`\n[2/5] Fetching prompt from ${directSource}`);
|
|
782
|
+
const raw = await fetchPromptSource(directSource);
|
|
783
|
+
console.log(` Fetched ${raw.length} bytes.`);
|
|
784
|
+
state.promptSource = directSource;
|
|
785
|
+
state.rawPrompt = raw;
|
|
786
|
+
saveState(opts.dataDir, state);
|
|
787
|
+
settled = true;
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
const result = await handleMissingPrompt(state.model!, reader);
|
|
791
|
+
if (result === null) {
|
|
792
|
+
console.log(' Aborted.');
|
|
793
|
+
process.exit(0);
|
|
794
|
+
}
|
|
795
|
+
if (result.kind === 'switch-model') {
|
|
796
|
+
// Re-check retirement on the substituted model.
|
|
797
|
+
const retire = checkRetirement(result.newModel);
|
|
798
|
+
if (retire.retired) {
|
|
799
|
+
const replacement = await handleRetiredModel(result.newModel, retire.era!, retire.closestLiving, reader);
|
|
800
|
+
if (replacement === null) process.exit(0);
|
|
801
|
+
state.originalModel = state.originalModel ?? state.model;
|
|
802
|
+
state.model = replacement;
|
|
803
|
+
} else {
|
|
804
|
+
state.originalModel = state.originalModel ?? state.model;
|
|
805
|
+
state.model = result.newModel;
|
|
806
|
+
}
|
|
807
|
+
saveState(opts.dataDir, state);
|
|
808
|
+
console.log(` Model switched to "${state.model}". Re-evaluating prompt source...`);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
// result.kind === 'prompt' — including the legitimate empty-text case.
|
|
812
|
+
state.promptSource = result.source;
|
|
813
|
+
state.rawPrompt = result.text;
|
|
814
|
+
saveState(opts.dataDir, state);
|
|
815
|
+
console.log(` Using prompt source: ${result.source} (${result.text.length} bytes).`);
|
|
816
|
+
settled = true;
|
|
817
|
+
}
|
|
818
|
+
} else {
|
|
819
|
+
console.log(
|
|
820
|
+
`\n[2/5] Resumed: prompt cached (${state.rawPrompt?.length ?? 0} bytes from ${state.promptSource})`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
// Read model after step 2, since switch-model in the prompt dialog may have updated it.
|
|
824
|
+
const model = state.model!;
|
|
825
|
+
|
|
826
|
+
// -- Step 3: optional model-driven adjustment --
|
|
827
|
+
let workingPrompt = state.adjustedPrompt ?? state.rawPrompt!;
|
|
828
|
+
let changeSummary = state.changeSummary ?? '';
|
|
829
|
+
if (state.adjustedPrompt === undefined) {
|
|
830
|
+
console.log('\n[3/5] Adjust prompt for transplant context?');
|
|
831
|
+
console.log(` Calls ${model} (the same model that will read the prompt back) with editing`);
|
|
832
|
+
console.log(' instructions: drop web-only tools/products, update dates, preserve identity/behavior.');
|
|
833
|
+
console.log(' Cost depends on model (Sonnet tier ~$0.05–0.15; Opus tier ~$0.30–0.80); ~10–60s.');
|
|
834
|
+
const doAdjust = await askYesNo(reader, ' Run adjustment?');
|
|
835
|
+
if (doAdjust) {
|
|
836
|
+
console.log(` Calling ${model}...`);
|
|
837
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
838
|
+
try {
|
|
839
|
+
const { adjustedPrompt, changeSummary: cs } = await adjustPromptWithModel(state.rawPrompt!, model, today);
|
|
840
|
+
workingPrompt = adjustedPrompt;
|
|
841
|
+
changeSummary = cs;
|
|
842
|
+
state.adjustedPrompt = adjustedPrompt;
|
|
843
|
+
state.changeSummary = cs;
|
|
844
|
+
saveState(opts.dataDir, state);
|
|
845
|
+
console.log(' Done. Change summary:');
|
|
846
|
+
for (const line of cs.split('\n')) console.log(` ${line}`);
|
|
847
|
+
} catch (e) {
|
|
848
|
+
console.error(` Adjustment failed: ${(e as Error).message}`);
|
|
849
|
+
console.error(' Falling back to raw prompt.');
|
|
850
|
+
state.adjustedPrompt = state.rawPrompt;
|
|
851
|
+
state.changeSummary = '(adjustment failed; raw prompt used)';
|
|
852
|
+
saveState(opts.dataDir, state);
|
|
853
|
+
}
|
|
854
|
+
} else {
|
|
855
|
+
state.adjustedPrompt = state.rawPrompt;
|
|
856
|
+
state.changeSummary = '(adjustment skipped by operator)';
|
|
857
|
+
saveState(opts.dataDir, state);
|
|
858
|
+
}
|
|
859
|
+
} else {
|
|
860
|
+
console.log(`\n[3/5] Resumed: adjusted prompt cached.`);
|
|
861
|
+
if (state.changeSummary) {
|
|
862
|
+
console.log(' Cached change summary:');
|
|
863
|
+
for (const line of state.changeSummary.split('\n').slice(0, 8)) console.log(` ${line}`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// -- Step 4: editor pass on system prompt --
|
|
868
|
+
if (state.finalSystemPrompt === undefined) {
|
|
869
|
+
const header = `<!--\n Final system prompt. Save = include verbatim. Empty buffer on save = abort (sysprompt is required).\n Change summary from step 3:\n${changeSummary
|
|
870
|
+
.split('\n')
|
|
871
|
+
.map((l) => ` | ${l}`)
|
|
872
|
+
.join('\n')}\n-->\n\n`;
|
|
873
|
+
console.log('\n[4/5] Opening system prompt in $EDITOR...');
|
|
874
|
+
const edited = editInline(workingPrompt, '.md', header).trim();
|
|
875
|
+
if (edited.length === 0) {
|
|
876
|
+
console.error(' Empty system prompt — aborting.');
|
|
877
|
+
process.exit(1);
|
|
878
|
+
}
|
|
879
|
+
state.finalSystemPrompt = edited;
|
|
880
|
+
saveState(opts.dataDir, state);
|
|
881
|
+
console.log(` Saved ${edited.length} bytes.`);
|
|
882
|
+
} else {
|
|
883
|
+
console.log(`\n[4/5] Resumed: final system prompt cached (${state.finalSystemPrompt.length} bytes).`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// -- Step 5: editor pass on memories --
|
|
887
|
+
if (state.finalMemoriesBlock === undefined) {
|
|
888
|
+
const rawMemories = loadMemoriesBlock(opts.exportDir);
|
|
889
|
+
if (rawMemories === null) {
|
|
890
|
+
console.log('\n[5/5] No memories.json present (or empty) — skipping memory section.');
|
|
891
|
+
state.finalMemoriesBlock = '';
|
|
892
|
+
saveState(opts.dataDir, state);
|
|
893
|
+
} else {
|
|
894
|
+
console.log(`\n[5/5] Opening memories block in $EDITOR (${rawMemories.length} bytes)...`);
|
|
895
|
+
const header = `<!--\n conversations_memory from claude.ai. Save = include verbatim.\n Empty buffer on save = omit memories from the recipe.\n Edit freely to redact or rephrase anything you don't want surfaced.\n-->\n\n`;
|
|
896
|
+
const edited = editInline(rawMemories, '.md', header).trim();
|
|
897
|
+
state.finalMemoriesBlock = edited;
|
|
898
|
+
saveState(opts.dataDir, state);
|
|
899
|
+
console.log(` Saved ${edited.length} bytes${edited.length === 0 ? ' — memories omitted' : ''}.`);
|
|
900
|
+
}
|
|
901
|
+
} else {
|
|
902
|
+
console.log(`\n[5/5] Resumed: memories block cached (${state.finalMemoriesBlock.length} bytes).`);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// -- Compose & write recipe --
|
|
906
|
+
if (!existsSync(opts.addendumPath)) {
|
|
907
|
+
console.error(`\nAddendum file not found: ${opts.addendumPath}`);
|
|
908
|
+
process.exit(1);
|
|
909
|
+
}
|
|
910
|
+
const addendum = readFileSync(opts.addendumPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
911
|
+
|
|
912
|
+
const recipe = composeRecipe({
|
|
913
|
+
model,
|
|
914
|
+
systemPrompt: state.finalSystemPrompt!,
|
|
915
|
+
memoriesBlock: state.finalMemoriesBlock || null,
|
|
916
|
+
addendum,
|
|
917
|
+
recipeName: `Continued from claude.ai (${model})`,
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
mkdirSync(dirname(opts.out), { recursive: true });
|
|
921
|
+
writeFileSync(opts.out, JSON.stringify(recipe, null, 2) + '\n');
|
|
922
|
+
|
|
923
|
+
const composed = (recipe.agent as Record<string, unknown>).systemPrompt as string;
|
|
924
|
+
console.log(`\n→ Recipe written to ${opts.out}`);
|
|
925
|
+
console.log(` Composed system prompt: ${composed.length} bytes (~${Math.round(composed.length / 4)} tokens)`);
|
|
926
|
+
console.log(` - base/edited prompt: ${state.finalSystemPrompt!.length} bytes`);
|
|
927
|
+
console.log(` - memories block: ${state.finalMemoriesBlock?.length ?? 0} bytes`);
|
|
928
|
+
console.log(` - transplant addendum: ${addendum.length} bytes`);
|
|
929
|
+
|
|
930
|
+
// -- Optional warmup chain --
|
|
931
|
+
if (!opts.noWarmup) {
|
|
932
|
+
console.log('');
|
|
933
|
+
const warmup = await askYesNo(reader, 'Start a warmup pass now?', false);
|
|
934
|
+
if (warmup) {
|
|
935
|
+
const sessionRef = await askText(reader, ' Session name or id to warm up (leave blank to skip): ');
|
|
936
|
+
if (sessionRef) {
|
|
937
|
+
console.log(` Spawning warmup-session.ts for "${sessionRef}"...\n`);
|
|
938
|
+
const scriptPath = resolve(import.meta.dir, 'warmup-session.ts');
|
|
939
|
+
const result = spawnSync('bun', [scriptPath, sessionRef, '--data-dir', opts.dataDir, '--model', model], {
|
|
940
|
+
stdio: 'inherit',
|
|
941
|
+
});
|
|
942
|
+
process.exit(result.status ?? 0);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
console.log('\nDone. Open the session with:');
|
|
948
|
+
console.log(` bun src/index.ts ${opts.out} --session "<name-or-id>"`);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (import.meta.main) {
|
|
952
|
+
main().catch((e) => {
|
|
953
|
+
console.error('\nEvacuator failed:', e);
|
|
954
|
+
process.exit(1);
|
|
955
|
+
});
|
|
956
|
+
}
|