@blockrun/franklin 3.10.1 → 3.10.4
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/dist/agent/error-classifier.js +8 -1
- package/dist/agent/loop.d.ts +18 -1
- package/dist/agent/loop.js +77 -0
- package/dist/commands/start.d.ts +4 -0
- package/dist/commands/start.js +57 -5
- package/dist/index.js +12 -2
- package/dist/panel/html.js +9 -2
- package/dist/router/local-elo.d.ts +1 -1
- package/dist/router/local-elo.js +7 -0
- package/dist/session/from-import.d.ts +18 -0
- package/dist/session/from-import.js +553 -0
- package/dist/stats/tracker.d.ts +4 -0
- package/dist/stats/tracker.js +30 -4
- package/dist/ui/app.js +31 -13
- package/package.json +2 -2
|
@@ -44,9 +44,16 @@ export function classifyAgentError(message) {
|
|
|
44
44
|
'429',
|
|
45
45
|
'rate limit',
|
|
46
46
|
'too many requests',
|
|
47
|
+
'too many tokens', // Anthropic per-day TPM cap leak via gateway
|
|
48
|
+
'tokens per day',
|
|
49
|
+
'please wait before trying',
|
|
50
|
+
'quota exceeded',
|
|
47
51
|
])) {
|
|
52
|
+
// 1 retry is plenty: a per-second rate limit clears in seconds (one
|
|
53
|
+
// backoff covers it), but a per-day TPM quota won't clear in this
|
|
54
|
+
// session at all — caller falls back to a different provider after.
|
|
48
55
|
return {
|
|
49
|
-
category: 'rate_limit', label: 'RateLimit', isTransient: true,
|
|
56
|
+
category: 'rate_limit', label: 'RateLimit', isTransient: true, maxRetries: 1,
|
|
50
57
|
suggestion: 'Try /model to switch to a different model, or wait a moment and /retry.',
|
|
51
58
|
};
|
|
52
59
|
}
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -2,7 +2,24 @@
|
|
|
2
2
|
* Franklin Agent Loop
|
|
3
3
|
* The core reasoning-action cycle: prompt → model → extract capabilities → execute → repeat.
|
|
4
4
|
*/
|
|
5
|
-
import type { AgentConfig, Dialogue, StreamEvent } from './types.js';
|
|
5
|
+
import type { AgentConfig, ContentPart, Dialogue, StreamEvent } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Detect when the gateway leaked an upstream rate-limit / quota error as a
|
|
8
|
+
* 200-OK text content block instead of a real HTTP error. The Anthropic
|
|
9
|
+
* provider in particular surfaces per-day TPM exhaustion as a bracketed
|
|
10
|
+
* "[Error: Too many tokens per day, please wait before trying again.]"
|
|
11
|
+
* message glued into the assistant text channel, which then poisons grounding
|
|
12
|
+
* checks and gets persisted to session history as if it were a real reply.
|
|
13
|
+
*
|
|
14
|
+
* Treat any assistant turn whose entire text payload is a single bracketed
|
|
15
|
+
* `[Error: ...]` line — and contains no tool_use / thinking blocks — as a
|
|
16
|
+
* masquerading transport error. The caller throws to let the existing
|
|
17
|
+
* classifier + retry path take over.
|
|
18
|
+
*/
|
|
19
|
+
export declare function looksLikeGatewayErrorAsText(parts: ContentPart[]): {
|
|
20
|
+
match: boolean;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
6
23
|
/**
|
|
7
24
|
* Identify models known to hallucinate tool calls (invented names, literal
|
|
8
25
|
* `[TOOLCALL]` / `<tool_call>` text in answers) — they need the explicit
|
package/dist/agent/loop.js
CHANGED
|
@@ -206,6 +206,42 @@ function stripMediaFromHistory(history) {
|
|
|
206
206
|
});
|
|
207
207
|
return { history: stripped ? result : history, stripped };
|
|
208
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Detect when the gateway leaked an upstream rate-limit / quota error as a
|
|
211
|
+
* 200-OK text content block instead of a real HTTP error. The Anthropic
|
|
212
|
+
* provider in particular surfaces per-day TPM exhaustion as a bracketed
|
|
213
|
+
* "[Error: Too many tokens per day, please wait before trying again.]"
|
|
214
|
+
* message glued into the assistant text channel, which then poisons grounding
|
|
215
|
+
* checks and gets persisted to session history as if it were a real reply.
|
|
216
|
+
*
|
|
217
|
+
* Treat any assistant turn whose entire text payload is a single bracketed
|
|
218
|
+
* `[Error: ...]` line — and contains no tool_use / thinking blocks — as a
|
|
219
|
+
* masquerading transport error. The caller throws to let the existing
|
|
220
|
+
* classifier + retry path take over.
|
|
221
|
+
*/
|
|
222
|
+
export function looksLikeGatewayErrorAsText(parts) {
|
|
223
|
+
if (parts.length === 0)
|
|
224
|
+
return { match: false, message: '' };
|
|
225
|
+
// Reject if any non-text content (real tool calls, real thinking) was emitted.
|
|
226
|
+
const textParts = [];
|
|
227
|
+
for (const p of parts) {
|
|
228
|
+
if (p.type === 'tool_use')
|
|
229
|
+
return { match: false, message: '' };
|
|
230
|
+
if (p.type === 'text' && typeof p.text === 'string') {
|
|
231
|
+
textParts.push(p.text);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const joined = textParts.join('').trim();
|
|
235
|
+
if (!joined)
|
|
236
|
+
return { match: false, message: '' };
|
|
237
|
+
// Pattern: `[Error: ...]` taking up the entire text payload, modulo
|
|
238
|
+
// surrounding whitespace. Allow the bracket to be the whole message OR
|
|
239
|
+
// the message to start with it (some gateways append a stray newline).
|
|
240
|
+
const m = /^\[Error:\s*([^\]]+?)\]\s*$/.exec(joined);
|
|
241
|
+
if (!m)
|
|
242
|
+
return { match: false, message: '' };
|
|
243
|
+
return { match: true, message: m[1].trim() };
|
|
244
|
+
}
|
|
209
245
|
/**
|
|
210
246
|
* Calculate backoff delay with jitter to avoid thundering herd.
|
|
211
247
|
* Base: exponential (2^attempt * 1000ms), jitter: ±25%.
|
|
@@ -1024,6 +1060,33 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1024
1060
|
continue; // Retry with next model
|
|
1025
1061
|
}
|
|
1026
1062
|
}
|
|
1063
|
+
// ── Rate-limit / quota: auto-fallback to a different provider ──
|
|
1064
|
+
// Per-day TPM caps (Anthropic) won't clear in this session; per-second
|
|
1065
|
+
// limits already had their backoff retry above and still failed. In
|
|
1066
|
+
// both cases, the productive next move is to run the same turn on a
|
|
1067
|
+
// model from a different provider rather than thrash on the failing
|
|
1068
|
+
// one. Mirror the payment fallback shape: mark the model as failed
|
|
1069
|
+
// for this turn and pick the next free model that hasn't failed yet.
|
|
1070
|
+
if (classified.category === 'rate_limit') {
|
|
1071
|
+
turnFailedModels.add(config.model);
|
|
1072
|
+
if (lastRoutedCategory) {
|
|
1073
|
+
recordOutcome(lastRoutedCategory, config.model, 'rate_limit');
|
|
1074
|
+
}
|
|
1075
|
+
const FREE_MODELS = ['nvidia/qwen3-coder-480b', 'nvidia/llama-4-maverick', 'nvidia/glm-4.7'];
|
|
1076
|
+
const nextFree = FREE_MODELS.find(m => !turnFailedModels.has(m));
|
|
1077
|
+
if (nextFree) {
|
|
1078
|
+
const oldModel = config.model;
|
|
1079
|
+
config.model = nextFree;
|
|
1080
|
+
config.onModelChange?.(nextFree, 'system');
|
|
1081
|
+
// Reset retry counter — the new model gets its own retry budget.
|
|
1082
|
+
recoveryAttempts = 0;
|
|
1083
|
+
onEvent({
|
|
1084
|
+
kind: 'text_delta',
|
|
1085
|
+
text: `\n*${oldModel} rate-limited — switching to ${nextFree}*\n`,
|
|
1086
|
+
});
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1027
1090
|
// ── Unrecoverable: show error with suggestion from classifier ──
|
|
1028
1091
|
const suggestion = classified.suggestion ? `\nTip: ${classified.suggestion}` : '';
|
|
1029
1092
|
onEvent({
|
|
@@ -1165,6 +1228,20 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1165
1228
|
lastSessionActivity = Date.now();
|
|
1166
1229
|
continue; // Retry with higher limit
|
|
1167
1230
|
}
|
|
1231
|
+
// ── Gateway error masquerading as text (BlockRun → Anthropic TPM) ──
|
|
1232
|
+
// Some upstreams swallow rate-limit / quota errors and emit them as a
|
|
1233
|
+
// single bracketed text block on a 200 OK. Persisting that as a real
|
|
1234
|
+
// assistant reply poisons history (the next turn sees an "answer" that
|
|
1235
|
+
// is actually a transport error) and triggers grounding-check retries
|
|
1236
|
+
// that hit the same wall. Detect, throw into the classifier, and let
|
|
1237
|
+
// the existing recovery flow handle it.
|
|
1238
|
+
const gatewayErr = looksLikeGatewayErrorAsText(responseParts);
|
|
1239
|
+
if (gatewayErr.match) {
|
|
1240
|
+
if (config.debug) {
|
|
1241
|
+
console.error(`[franklin] Gateway returned an error text in lieu of an answer (${resolvedModel}): ${gatewayErr.message}`);
|
|
1242
|
+
}
|
|
1243
|
+
throw new Error(gatewayErr.message);
|
|
1244
|
+
}
|
|
1168
1245
|
// Reset recovery counter on successful completion
|
|
1169
1246
|
recoveryAttempts = 0;
|
|
1170
1247
|
// Extract tool invocations (text/thinking already streamed in real-time)
|
package/dist/commands/start.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ interface StartOptions {
|
|
|
4
4
|
debug?: boolean;
|
|
5
5
|
trust?: boolean;
|
|
6
6
|
version?: string;
|
|
7
|
+
/** Start a new Franklin session seeded from another agent's saved context. */
|
|
8
|
+
from?: string;
|
|
9
|
+
/** Optional external agent session id/path for --from. If omitted, show a picker. */
|
|
10
|
+
fromSessionId?: string;
|
|
7
11
|
/** Resume: explicit session ID, or true for "most recent in cwd", or 'picker' to prompt */
|
|
8
12
|
resume?: string | boolean | 'picker';
|
|
9
13
|
/** Continue: resume most recent session matching the current working directory */
|
package/dist/commands/start.js
CHANGED
|
@@ -81,7 +81,48 @@ export async function startCommand(options) {
|
|
|
81
81
|
// old nvidia-nemotron default, which stubbed tool use.
|
|
82
82
|
model = 'blockrun/auto';
|
|
83
83
|
}
|
|
84
|
-
|
|
84
|
+
let workDir = process.cwd();
|
|
85
|
+
let importedKickoffPrompt;
|
|
86
|
+
if (options.from) {
|
|
87
|
+
const { importExternalSessionAsFranklin, parseExternalAgentSource } = await import('../session/from-import.js');
|
|
88
|
+
const source = parseExternalAgentSource(options.from);
|
|
89
|
+
if (!source) {
|
|
90
|
+
console.error(chalk.red(`Unknown --from source: ${options.from}`));
|
|
91
|
+
console.error(chalk.dim('Supported sources: claude, codex'));
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const imported = await importExternalSessionAsFranklin(source, options.fromSessionId, { model, workDir });
|
|
97
|
+
if (imported.imported.cwd) {
|
|
98
|
+
try {
|
|
99
|
+
process.chdir(imported.imported.cwd);
|
|
100
|
+
workDir = process.cwd();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Keep the caller's cwd if the source session directory no longer exists.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
options.resume = imported.sessionId;
|
|
107
|
+
options.continue = false;
|
|
108
|
+
importedKickoffPrompt = [
|
|
109
|
+
`Continue from the imported ${source} handoff context.`,
|
|
110
|
+
'Briefly explain what you understand the previous session was working on, what state it appears to be in, and the most likely next step.',
|
|
111
|
+
'Do not claim you resumed or modified the source agent session. This is a new Franklin session with imported context awareness.',
|
|
112
|
+
'If the next action is clear, offer to proceed; if it is not clear, ask one concise question.',
|
|
113
|
+
].join('\n');
|
|
114
|
+
console.log(chalk.green(` Imported ${source} context into Franklin session ${imported.sessionId.slice(0, 24)}…`));
|
|
115
|
+
console.log(chalk.dim(` Source session: ${imported.imported.id}`));
|
|
116
|
+
if (imported.imported.cwd)
|
|
117
|
+
console.log(chalk.dim(` Dir: ${workDir}`));
|
|
118
|
+
console.log('');
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
console.error(chalk.red(err.message));
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
85
126
|
// --prompt batch mode: skip all interactive startup UI/side effects so
|
|
86
127
|
// stdout stays clean for scripts and one-shot callers. Keep the capability surface to the
|
|
87
128
|
// built-ins only — no panel, no MCP autoconnect, no wallet/banner chatter.
|
|
@@ -314,10 +355,10 @@ export async function startCommand(options) {
|
|
|
314
355
|
if (process.stdin.isTTY) {
|
|
315
356
|
await runWithInkUI(agentConfig, model, workDir, version, walletInfo, (cb) => {
|
|
316
357
|
onBalanceFetched = cb;
|
|
317
|
-
}, fetchBalance);
|
|
358
|
+
}, fetchBalance, importedKickoffPrompt);
|
|
318
359
|
}
|
|
319
360
|
else {
|
|
320
|
-
await runWithBasicUI(agentConfig, model, workDir);
|
|
361
|
+
await runWithBasicUI(agentConfig, model, workDir, importedKickoffPrompt);
|
|
321
362
|
}
|
|
322
363
|
}
|
|
323
364
|
// ─── One-shot mode (franklin --prompt "...") ──────────────────────────────
|
|
@@ -348,7 +389,7 @@ async function runOneShot(agentConfig, prompt) {
|
|
|
348
389
|
return exitCode;
|
|
349
390
|
}
|
|
350
391
|
// ─── Ink UI (interactive terminal) ─────────────────────────────────────────
|
|
351
|
-
async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, onBalanceReady, fetchBalance) {
|
|
392
|
+
async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, onBalanceReady, fetchBalance, initialInput) {
|
|
352
393
|
const startSnapshot = snapshotStats();
|
|
353
394
|
const ui = launchInkUI({
|
|
354
395
|
model,
|
|
@@ -381,8 +422,13 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
|
|
|
381
422
|
});
|
|
382
423
|
}
|
|
383
424
|
let sessionHistory;
|
|
425
|
+
let deliveredInitialInput = false;
|
|
384
426
|
try {
|
|
385
427
|
sessionHistory = await interactiveSession(agentConfig, async () => {
|
|
428
|
+
if (initialInput && !deliveredInitialInput) {
|
|
429
|
+
deliveredInitialInput = true;
|
|
430
|
+
return initialInput;
|
|
431
|
+
}
|
|
386
432
|
const input = await ui.waitForInput();
|
|
387
433
|
if (input === null)
|
|
388
434
|
return null;
|
|
@@ -441,14 +487,20 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
|
|
|
441
487
|
console.log(chalk.dim('\nGoodbye.\n'));
|
|
442
488
|
}
|
|
443
489
|
// ─── Basic readline UI (piped input) ───────────────────────────────────────
|
|
444
|
-
async function runWithBasicUI(agentConfig, model, workDir) {
|
|
490
|
+
async function runWithBasicUI(agentConfig, model, workDir, initialInput) {
|
|
445
491
|
const { TerminalUI } = await import('../ui/terminal.js');
|
|
446
492
|
const ui = new TerminalUI();
|
|
447
493
|
ui.printWelcome(model, workDir);
|
|
448
494
|
const startSnapshot = snapshotStats();
|
|
449
495
|
let lastTerminalPrompt = '';
|
|
496
|
+
let deliveredInitialInput = false;
|
|
450
497
|
try {
|
|
451
498
|
await interactiveSession(agentConfig, async () => {
|
|
499
|
+
if (initialInput && !deliveredInitialInput) {
|
|
500
|
+
deliveredInitialInput = true;
|
|
501
|
+
lastTerminalPrompt = initialInput;
|
|
502
|
+
return initialInput;
|
|
503
|
+
}
|
|
452
504
|
while (true) {
|
|
453
505
|
const input = await ui.promptUser();
|
|
454
506
|
if (input === null)
|
package/dist/index.js
CHANGED
|
@@ -38,15 +38,17 @@ program
|
|
|
38
38
|
.action((chain) => setupCommand(chain));
|
|
39
39
|
program
|
|
40
40
|
.command('start')
|
|
41
|
+
.argument('[fromSessionId]', 'External agent session id/path for --from')
|
|
41
42
|
.description('Start the franklin agent')
|
|
42
43
|
.option('-m, --model <model>', 'Model to use (e.g. openai/gpt-5.5, anthropic/claude-sonnet-4.6). Default from config or claude-sonnet-4.6')
|
|
43
44
|
.option('--debug', 'Enable debug logging')
|
|
44
45
|
.option('--trust', 'Trust mode — skip permission prompts for all tools')
|
|
46
|
+
.option('--from <agent>', 'Start a new Franklin session from another agent context (claude or codex)')
|
|
45
47
|
.option('-r, --resume [sessionId]', 'Resume a session by ID (or show picker if omitted)')
|
|
46
48
|
.option('-c, --continue', 'Continue the most recent session in this directory')
|
|
47
49
|
.option('--max-spend <usd>', 'Hard USD cap on total session API spend — session stops when exceeded')
|
|
48
50
|
.option('-p, --prompt <text>', 'Run a single prompt non-interactively (for batch/scripted use)')
|
|
49
|
-
.action((options) => startCommand({ ...options, version }));
|
|
51
|
+
.action((fromSessionId, options) => startCommand({ ...options, fromSessionId, version }));
|
|
50
52
|
program
|
|
51
53
|
.command('resume [sessionId]')
|
|
52
54
|
.description('Resume a saved Franklin session (alias for: franklin --resume)')
|
|
@@ -236,7 +238,7 @@ const args = process.argv.slice(2);
|
|
|
236
238
|
const firstArg = args[0];
|
|
237
239
|
const HELP_FLAGS = new Set(['-h', '--help']);
|
|
238
240
|
const VERSION_FLAGS = new Set(['-V', '--version']);
|
|
239
|
-
const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model', '-r', '--resume', '-c', '--continue', '-p', '--prompt', '--max-spend']);
|
|
241
|
+
const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model', '--from', '-r', '--resume', '-c', '--continue', '-p', '--prompt', '--max-spend']);
|
|
240
242
|
function hasAnyFlag(argv, flags) {
|
|
241
243
|
return argv.some(arg => flags.has(arg));
|
|
242
244
|
}
|
|
@@ -260,6 +262,14 @@ function parseStartFlags(argv, startIdx = 0) {
|
|
|
260
262
|
else if (arg === '--max-spend' && argv[i + 1]) {
|
|
261
263
|
opts.maxSpend = argv[++i];
|
|
262
264
|
}
|
|
265
|
+
else if (arg === '--from') {
|
|
266
|
+
opts.from = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : '';
|
|
267
|
+
const next = argv[i + 1];
|
|
268
|
+
if (next && !next.startsWith('-')) {
|
|
269
|
+
opts.fromSessionId = next;
|
|
270
|
+
i++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
263
273
|
else if (arg === '-c' || arg === '--continue') {
|
|
264
274
|
opts.continue = true;
|
|
265
275
|
}
|
package/dist/panel/html.js
CHANGED
|
@@ -779,8 +779,15 @@ async function loadOverview() {
|
|
|
779
779
|
document.getElementById('period-info').textContent = stats.period || '';
|
|
780
780
|
|
|
781
781
|
if (stats.opusCost > 0) {
|
|
782
|
-
|
|
783
|
-
|
|
782
|
+
// tracker.ts now returns saved already clamped to >= 0 and opusCost
|
|
783
|
+
// already inclusive of media (so comparing to totalCostUsd is
|
|
784
|
+
// apples-to-apples). Older summaries — or the rare path where saved
|
|
785
|
+
// is undefined — get the same Math.max clamp here so the panel
|
|
786
|
+
// never shows a negative dollar amount.
|
|
787
|
+
const saved = Math.max(0, stats.saved != null ? stats.saved : (stats.opusCost - stats.totalCostUsd));
|
|
788
|
+
const pct = stats.savedPct != null
|
|
789
|
+
? Math.max(0, stats.savedPct)
|
|
790
|
+
: (stats.opusCost > 0 ? Math.max(0, (saved / stats.opusCost) * 100) : 0);
|
|
784
791
|
document.getElementById('savings-hero').style.display = 'flex';
|
|
785
792
|
document.getElementById('savings-amount').textContent = usdBig(saved);
|
|
786
793
|
document.getElementById('savings-pct').textContent = pct.toFixed(0) + '%';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Storage: ~/.blockrun/router-history.jsonl (append-only, capped 2000 records)
|
|
6
6
|
* Never uploaded — purely local personalization.
|
|
7
7
|
*/
|
|
8
|
-
export type Outcome = 'continued' | 'switched' | 'retried' | 'error' | 'max_turns' | 'payment';
|
|
8
|
+
export type Outcome = 'continued' | 'switched' | 'retried' | 'error' | 'max_turns' | 'payment' | 'rate_limit';
|
|
9
9
|
/**
|
|
10
10
|
* Record a model outcome for local learning.
|
|
11
11
|
*/
|
package/dist/router/local-elo.js
CHANGED
|
@@ -92,6 +92,13 @@ export function computeLocalElo() {
|
|
|
92
92
|
case 'payment':
|
|
93
93
|
delta = -K_FACTOR * 1.5;
|
|
94
94
|
break;
|
|
95
|
+
// Rate-limited: provider isn't broken, just exhausted right now.
|
|
96
|
+
// Penalize less than payment (which won't clear without action) but
|
|
97
|
+
// more than a generic error so the router avoids the same provider
|
|
98
|
+
// for the rest of the session.
|
|
99
|
+
case 'rate_limit':
|
|
100
|
+
delta = -K_FACTOR * 1.2;
|
|
101
|
+
break;
|
|
95
102
|
case 'max_turns':
|
|
96
103
|
delta = -K_FACTOR * 0.3;
|
|
97
104
|
break;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ExternalAgentSource = 'claude' | 'codex';
|
|
2
|
+
export interface ExternalSessionCandidate {
|
|
3
|
+
id: string;
|
|
4
|
+
source: ExternalAgentSource;
|
|
5
|
+
cwd?: string;
|
|
6
|
+
summary?: string;
|
|
7
|
+
updatedAt: number;
|
|
8
|
+
filePath: string;
|
|
9
|
+
bytes: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseExternalAgentSource(input: string): ExternalAgentSource | null;
|
|
12
|
+
export declare function importExternalSessionAsFranklin(source: ExternalAgentSource, externalSessionId: string | undefined, opts: {
|
|
13
|
+
model: string;
|
|
14
|
+
workDir: string;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
sessionId: string;
|
|
17
|
+
imported: ExternalSessionCandidate;
|
|
18
|
+
}>;
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { appendToSession, createSessionId, updateSessionMeta } from './storage.js';
|
|
7
|
+
const MAX_FILES_PER_SOURCE = 500;
|
|
8
|
+
const MAX_MESSAGES_IN_HANDOFF = 24;
|
|
9
|
+
const MAX_TOOL_EVENTS_IN_HANDOFF = 18;
|
|
10
|
+
const MAX_TEXT_CHARS = 3000;
|
|
11
|
+
const MAX_HANDOFF_CHARS = 24000;
|
|
12
|
+
export function parseExternalAgentSource(input) {
|
|
13
|
+
const normalized = input.trim().toLowerCase();
|
|
14
|
+
return normalized === 'claude' || normalized === 'codex' ? normalized : null;
|
|
15
|
+
}
|
|
16
|
+
export async function importExternalSessionAsFranklin(source, externalSessionId, opts) {
|
|
17
|
+
const candidates = discoverExternalSessions(source);
|
|
18
|
+
if (candidates.length === 0) {
|
|
19
|
+
throw new Error(`No ${source} sessions found.`);
|
|
20
|
+
}
|
|
21
|
+
if (!externalSessionId && !process.stdin.isTTY) {
|
|
22
|
+
throw new Error(`--from ${source} requires a session id when stdin is not interactive.`);
|
|
23
|
+
}
|
|
24
|
+
const picked = externalSessionId
|
|
25
|
+
? resolveExternalSession(candidates, externalSessionId)
|
|
26
|
+
: await pickExternalSession(source, candidates, opts.workDir);
|
|
27
|
+
if (!picked) {
|
|
28
|
+
throw new Error(`No ${source} session selected.`);
|
|
29
|
+
}
|
|
30
|
+
const parsed = parseExternalSession(picked);
|
|
31
|
+
const sessionId = createSessionId();
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const handoff = buildHandoffPrompt(parsed);
|
|
34
|
+
const handoffMessage = { role: 'user', content: handoff };
|
|
35
|
+
const ackMessage = {
|
|
36
|
+
role: 'assistant',
|
|
37
|
+
content: 'I have the imported session context and will continue from that state in this new Franklin session.',
|
|
38
|
+
};
|
|
39
|
+
appendToSession(sessionId, handoffMessage);
|
|
40
|
+
appendToSession(sessionId, ackMessage);
|
|
41
|
+
updateSessionMeta(sessionId, {
|
|
42
|
+
model: opts.model,
|
|
43
|
+
workDir: parsed.cwd || opts.workDir,
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
turnCount: 1,
|
|
47
|
+
messageCount: 2,
|
|
48
|
+
});
|
|
49
|
+
return { sessionId, imported: picked };
|
|
50
|
+
}
|
|
51
|
+
function discoverExternalSessions(source) {
|
|
52
|
+
const roots = source === 'codex' ? codexRoots() : claudeRoots();
|
|
53
|
+
const files = roots.flatMap((root) => walkSessionFiles(root, source));
|
|
54
|
+
const candidates = files
|
|
55
|
+
.map((filePath) => sessionCandidateFromFile(source, filePath))
|
|
56
|
+
.filter((candidate) => candidate !== null)
|
|
57
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
58
|
+
const byId = new Map();
|
|
59
|
+
for (const candidate of candidates) {
|
|
60
|
+
const existing = byId.get(candidate.id);
|
|
61
|
+
if (!existing || existing.updatedAt < candidate.updatedAt) {
|
|
62
|
+
byId.set(candidate.id, candidate);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Array.from(byId.values()).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
66
|
+
}
|
|
67
|
+
function codexRoots() {
|
|
68
|
+
const home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
69
|
+
return [path.join(home, 'sessions'), path.join(home, 'archived_sessions')];
|
|
70
|
+
}
|
|
71
|
+
function claudeRoots() {
|
|
72
|
+
const root = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
73
|
+
return [path.join(root, 'projects')];
|
|
74
|
+
}
|
|
75
|
+
function walkSessionFiles(root, source) {
|
|
76
|
+
const out = [];
|
|
77
|
+
const stack = [root];
|
|
78
|
+
while (stack.length > 0 && out.length < MAX_FILES_PER_SOURCE) {
|
|
79
|
+
const dir = stack.pop();
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const full = path.join(dir, entry.name);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
stack.push(full);
|
|
91
|
+
}
|
|
92
|
+
else if (entry.isFile() && isSessionFileName(source, entry.name)) {
|
|
93
|
+
out.push(full);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function isSessionFileName(source, name) {
|
|
100
|
+
if (source === 'codex')
|
|
101
|
+
return name.startsWith('rollout-') && name.endsWith('.jsonl');
|
|
102
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i.test(name);
|
|
103
|
+
}
|
|
104
|
+
function sessionCandidateFromFile(source, filePath) {
|
|
105
|
+
try {
|
|
106
|
+
const stats = fs.statSync(filePath);
|
|
107
|
+
const partial = source === 'codex' ? readCodexMeta(filePath) : readClaudeMeta(filePath);
|
|
108
|
+
const id = partial.id || idFromFileName(source, filePath);
|
|
109
|
+
if (!id)
|
|
110
|
+
return null;
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
source,
|
|
114
|
+
cwd: partial.cwd,
|
|
115
|
+
summary: partial.summary,
|
|
116
|
+
updatedAt: partial.updatedAt || stats.mtimeMs,
|
|
117
|
+
filePath,
|
|
118
|
+
bytes: stats.size,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function idFromFileName(source, filePath) {
|
|
126
|
+
const base = path.basename(filePath, '.jsonl');
|
|
127
|
+
if (source === 'codex')
|
|
128
|
+
return base.replace(/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-/, '');
|
|
129
|
+
return base;
|
|
130
|
+
}
|
|
131
|
+
function readCodexMeta(filePath) {
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const record of readJsonlPrefix(filePath, 180)) {
|
|
134
|
+
const type = stringProp(record, 'type');
|
|
135
|
+
if (type === 'session_meta') {
|
|
136
|
+
const payload = objectProp(record, 'payload');
|
|
137
|
+
out.cwd ||= stringProp(payload, 'cwd');
|
|
138
|
+
out.updatedAt ||= timestampMs(stringProp(payload, 'timestamp')) || timestampMs(stringProp(record, 'timestamp'));
|
|
139
|
+
}
|
|
140
|
+
if (!out.summary) {
|
|
141
|
+
const text = extractCodexMessageText(record);
|
|
142
|
+
if (text && codexRole(record) === 'user')
|
|
143
|
+
out.summary = cleanSummary(text);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
out.id = idFromFileName('codex', filePath);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function readClaudeMeta(filePath) {
|
|
150
|
+
const out = {};
|
|
151
|
+
for (const record of readJsonlPrefix(filePath, 180)) {
|
|
152
|
+
out.id ||= stringProp(record, 'sessionId');
|
|
153
|
+
out.cwd ||= stringProp(record, 'cwd');
|
|
154
|
+
const ts = timestampMs(stringProp(record, 'timestamp'));
|
|
155
|
+
if (ts)
|
|
156
|
+
out.updatedAt = Math.max(out.updatedAt || 0, ts);
|
|
157
|
+
if (!out.summary && stringProp(record, 'type') === 'user') {
|
|
158
|
+
const text = extractClaudeMessageText(record);
|
|
159
|
+
if (text && isHumanText(text))
|
|
160
|
+
out.summary = cleanSummary(text);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
out.id ||= idFromFileName('claude', filePath);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
function resolveExternalSession(candidates, input) {
|
|
167
|
+
const exact = candidates.find((candidate) => candidate.id === input || candidate.filePath === input);
|
|
168
|
+
if (exact)
|
|
169
|
+
return exact;
|
|
170
|
+
const matches = input.length >= 4 ? candidates.filter((candidate) => candidate.id.startsWith(input)) : [];
|
|
171
|
+
if (matches.length === 1)
|
|
172
|
+
return matches[0];
|
|
173
|
+
if (matches.length > 1)
|
|
174
|
+
throw new Error(`Ambiguous ${matches[0].source} session id prefix: ${input}`);
|
|
175
|
+
throw new Error(`No ${candidates[0]?.source ?? 'external'} session found with id: ${input}`);
|
|
176
|
+
}
|
|
177
|
+
async function pickExternalSession(source, candidates, workDir) {
|
|
178
|
+
const shown = prioritizeByCwd(candidates, workDir).slice(0, 20);
|
|
179
|
+
if (process.stdin.isTTY && process.stderr.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
180
|
+
return pickExternalSessionInteractive(source, shown, candidates, workDir);
|
|
181
|
+
}
|
|
182
|
+
console.error('');
|
|
183
|
+
console.error(chalk.bold(` Continue from ${source} session:\n`));
|
|
184
|
+
shown.forEach((session, index) => {
|
|
185
|
+
const here = session.cwd && samePath(session.cwd, workDir) ? chalk.green(' ●') : '';
|
|
186
|
+
console.error(` ${chalk.cyan(String(index + 1).padStart(2))}. ${chalk.dim(formatRelative(session.updatedAt).padEnd(8))} ` +
|
|
187
|
+
`${shortDir(session.cwd || '(unknown dir)').padEnd(42)} ${chalk.dim(session.id.slice(0, 12))}${here}`);
|
|
188
|
+
if (session.summary)
|
|
189
|
+
console.error(chalk.dim(` ${session.summary}`));
|
|
190
|
+
});
|
|
191
|
+
console.error('');
|
|
192
|
+
console.error(chalk.dim(' Enter a number or session id. Press Enter to cancel.'));
|
|
193
|
+
if (shown.some((session) => session.cwd && samePath(session.cwd, workDir))) {
|
|
194
|
+
console.error(chalk.dim(' ● = matches current directory'));
|
|
195
|
+
}
|
|
196
|
+
console.error('');
|
|
197
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: process.stdin.isTTY ?? false });
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
rl.question(chalk.bold(' session> '), (answer) => {
|
|
200
|
+
rl.close();
|
|
201
|
+
const trimmed = answer.trim();
|
|
202
|
+
if (!trimmed)
|
|
203
|
+
return resolve(null);
|
|
204
|
+
const num = Number.parseInt(trimmed, 10);
|
|
205
|
+
if (!Number.isNaN(num) && num >= 1 && num <= shown.length)
|
|
206
|
+
return resolve(shown[num - 1]);
|
|
207
|
+
try {
|
|
208
|
+
resolve(resolveExternalSession(candidates, trimmed));
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.error(chalk.red(` ${err.message}`));
|
|
212
|
+
resolve(null);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async function pickExternalSessionInteractive(source, shown, candidates, workDir) {
|
|
218
|
+
const pageSize = 5;
|
|
219
|
+
let selected = 0;
|
|
220
|
+
let offset = 0;
|
|
221
|
+
const render = () => {
|
|
222
|
+
offset = Math.min(offset, Math.max(0, shown.length - pageSize));
|
|
223
|
+
if (selected < offset)
|
|
224
|
+
offset = selected;
|
|
225
|
+
if (selected >= offset + pageSize)
|
|
226
|
+
offset = selected - pageSize + 1;
|
|
227
|
+
readline.cursorTo(process.stderr, 0, 0);
|
|
228
|
+
readline.clearScreenDown(process.stderr);
|
|
229
|
+
process.stderr.write('\x1b[?25l');
|
|
230
|
+
process.stderr.write(`\n${chalk.bold(` Continue from ${source} session`)}\n\n`);
|
|
231
|
+
process.stderr.write(chalk.dim(' ↑/↓ move · Enter select · type number/id then Enter · q/Esc cancel\n'));
|
|
232
|
+
if (shown.some((session) => session.cwd && samePath(session.cwd, workDir))) {
|
|
233
|
+
process.stderr.write(`${chalk.green(' ● Current Dir')} ${chalk.dim('= matches where you ran Franklin')}\n`);
|
|
234
|
+
}
|
|
235
|
+
process.stderr.write('\n');
|
|
236
|
+
const page = shown.slice(offset, offset + pageSize);
|
|
237
|
+
page.forEach((session, pageIndex) => {
|
|
238
|
+
const index = offset + pageIndex;
|
|
239
|
+
const active = index === selected;
|
|
240
|
+
const pointer = active ? chalk.cyan('›') : ' ';
|
|
241
|
+
const num = String(index + 1).padStart(2);
|
|
242
|
+
const here = !!(session.cwd && samePath(session.cwd, workDir));
|
|
243
|
+
const dir = shortDir(session.cwd || '(unknown dir)').padEnd(42);
|
|
244
|
+
const dirText = here ? chalk.green.bold(dir) : dir;
|
|
245
|
+
const hereText = here ? ` ${chalk.green.bold('● Current Dir')}` : '';
|
|
246
|
+
const line = `${pointer} ${num}. ${formatRelative(session.updatedAt).padEnd(8)} ${dirText} ${session.id.slice(0, 12)}${hereText}`;
|
|
247
|
+
process.stderr.write(active ? `${chalk.inverse(line)}\n` : `${line}\n`);
|
|
248
|
+
if (session.summary) {
|
|
249
|
+
const summary = truncate(session.summary, Math.max(60, (process.stderr.columns ?? 120) - 10));
|
|
250
|
+
process.stderr.write(chalk.dim(` ${summary}\n`));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
if (shown.length > pageSize) {
|
|
254
|
+
process.stderr.write(chalk.dim(`\n Showing ${offset + 1}-${Math.min(offset + pageSize, shown.length)} of ${shown.length}\n`));
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
process.stderr.write('\n');
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
let buffer = '';
|
|
262
|
+
const cleanup = () => {
|
|
263
|
+
process.stdin.off('data', onData);
|
|
264
|
+
process.stdin.setRawMode(false);
|
|
265
|
+
process.stdin.pause();
|
|
266
|
+
process.stderr.write('\x1b[?25h');
|
|
267
|
+
readline.cursorTo(process.stderr, 0, 0);
|
|
268
|
+
readline.clearScreenDown(process.stderr);
|
|
269
|
+
};
|
|
270
|
+
const finish = (value) => {
|
|
271
|
+
cleanup();
|
|
272
|
+
resolve(value);
|
|
273
|
+
};
|
|
274
|
+
const submitBuffer = () => {
|
|
275
|
+
const trimmed = buffer.trim();
|
|
276
|
+
if (!trimmed)
|
|
277
|
+
return finish(shown[selected] ?? null);
|
|
278
|
+
const num = Number.parseInt(trimmed, 10);
|
|
279
|
+
if (!Number.isNaN(num) && num >= 1 && num <= shown.length)
|
|
280
|
+
return finish(shown[num - 1]);
|
|
281
|
+
try {
|
|
282
|
+
return finish(resolveExternalSession(candidates, trimmed));
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
buffer = '';
|
|
286
|
+
render();
|
|
287
|
+
process.stderr.write(chalk.yellow(` ${err.message}\n`));
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const onData = (chunk) => {
|
|
291
|
+
const key = chunk.toString('utf8');
|
|
292
|
+
if (key === '\u0003') {
|
|
293
|
+
cleanup();
|
|
294
|
+
process.kill(process.pid, 'SIGINT');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (key === '\r' || key === '\n')
|
|
298
|
+
return submitBuffer();
|
|
299
|
+
if (key === '\u001b' || key.toLowerCase() === 'q')
|
|
300
|
+
return finish(null);
|
|
301
|
+
if (key === '\u001b[A') {
|
|
302
|
+
selected = Math.max(0, selected - 1);
|
|
303
|
+
render();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (key === '\u001b[B') {
|
|
307
|
+
selected = Math.min(shown.length - 1, selected + 1);
|
|
308
|
+
render();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (key === '\u001b[5~') {
|
|
312
|
+
selected = Math.max(0, selected - pageSize);
|
|
313
|
+
render();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (key === '\u001b[6~') {
|
|
317
|
+
selected = Math.min(shown.length - 1, selected + pageSize);
|
|
318
|
+
render();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (key === '\u007f') {
|
|
322
|
+
buffer = buffer.slice(0, -1);
|
|
323
|
+
render();
|
|
324
|
+
if (buffer)
|
|
325
|
+
process.stderr.write(chalk.dim(` filter/id: ${buffer}\n`));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (/^[\w./:-]$/.test(key)) {
|
|
329
|
+
buffer += key;
|
|
330
|
+
render();
|
|
331
|
+
process.stderr.write(chalk.dim(` filter/id: ${buffer}\n`));
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
process.stdin.setRawMode(true);
|
|
335
|
+
process.stdin.resume();
|
|
336
|
+
process.stdin.on('data', onData);
|
|
337
|
+
render();
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function prioritizeByCwd(candidates, workDir) {
|
|
341
|
+
return [...candidates].sort((a, b) => {
|
|
342
|
+
const ah = a.cwd && samePath(a.cwd, workDir) ? 1 : 0;
|
|
343
|
+
const bh = b.cwd && samePath(b.cwd, workDir) ? 1 : 0;
|
|
344
|
+
return bh - ah || b.updatedAt - a.updatedAt;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function parseExternalSession(candidate) {
|
|
348
|
+
const messages = [];
|
|
349
|
+
const toolEvents = [];
|
|
350
|
+
for (const record of readJsonlPrefix(candidate.filePath, 5000)) {
|
|
351
|
+
const role = candidate.source === 'codex' ? codexRole(record) : claudeRole(record);
|
|
352
|
+
const text = candidate.source === 'codex' ? extractCodexMessageText(record) : extractClaudeMessageText(record);
|
|
353
|
+
if (role && text && isHumanText(text)) {
|
|
354
|
+
messages.push({ role, text: truncate(text, MAX_TEXT_CHARS) });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const tool = candidate.source === 'codex' ? extractCodexToolEvent(record) : extractClaudeToolEvent(record);
|
|
358
|
+
if (tool)
|
|
359
|
+
toolEvents.push(tool);
|
|
360
|
+
}
|
|
361
|
+
return { ...candidate, messages: messages.slice(-MAX_MESSAGES_IN_HANDOFF), toolEvents: toolEvents.slice(-MAX_TOOL_EVENTS_IN_HANDOFF) };
|
|
362
|
+
}
|
|
363
|
+
function buildHandoffPrompt(session) {
|
|
364
|
+
const lines = [
|
|
365
|
+
'You are Franklin continuing work from another AI coding-agent session.',
|
|
366
|
+
'',
|
|
367
|
+
'This is a new Franklin session. Do not assume you can modify or resume the source agent session file. Use this handoff only as context awareness for what happened before.',
|
|
368
|
+
'',
|
|
369
|
+
'## Source Session',
|
|
370
|
+
`- Agent: ${session.source}`,
|
|
371
|
+
`- Session ID: ${session.id}`,
|
|
372
|
+
`- Original path: ${session.filePath}`,
|
|
373
|
+
`- Working directory: ${session.cwd || '(unknown)'}`,
|
|
374
|
+
`- Last active: ${new Date(session.updatedAt).toLocaleString()}`,
|
|
375
|
+
];
|
|
376
|
+
if (session.summary)
|
|
377
|
+
lines.push(`- Summary: ${session.summary}`);
|
|
378
|
+
if (session.toolEvents.length > 0) {
|
|
379
|
+
lines.push('', '## Recent Tool Activity');
|
|
380
|
+
for (const event of session.toolEvents)
|
|
381
|
+
lines.push(`- ${event}`);
|
|
382
|
+
}
|
|
383
|
+
if (session.messages.length > 0) {
|
|
384
|
+
lines.push('', '## Recent Conversation');
|
|
385
|
+
for (const msg of session.messages) {
|
|
386
|
+
lines.push('', `### ${msg.role}`, msg.text);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
lines.push('', '## Continue From Here', 'Ask the user what they want to do next if the next action is unclear. Otherwise continue the unfinished coding task using Franklin tools in the current workspace.');
|
|
390
|
+
return truncate(lines.join('\n'), MAX_HANDOFF_CHARS);
|
|
391
|
+
}
|
|
392
|
+
function readJsonlPrefix(filePath, maxLines) {
|
|
393
|
+
let content = '';
|
|
394
|
+
try {
|
|
395
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
const lines = content.split('\n').filter(Boolean);
|
|
401
|
+
const start = Math.max(0, lines.length - maxLines);
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const line of lines.slice(start)) {
|
|
404
|
+
try {
|
|
405
|
+
out.push(JSON.parse(line));
|
|
406
|
+
}
|
|
407
|
+
catch { /* skip bad lines */ }
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
function codexRole(record) {
|
|
412
|
+
const role = stringProp(record, 'role');
|
|
413
|
+
if (role === 'user' || role === 'assistant' || role === 'system')
|
|
414
|
+
return role;
|
|
415
|
+
const payload = objectProp(record, 'payload');
|
|
416
|
+
const type = stringProp(payload, 'type');
|
|
417
|
+
if (type === 'user_message')
|
|
418
|
+
return 'user';
|
|
419
|
+
if (type === 'agent_message' || type === 'assistant_message')
|
|
420
|
+
return 'assistant';
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
function claudeRole(record) {
|
|
424
|
+
const type = stringProp(record, 'type');
|
|
425
|
+
if (type === 'user' || type === 'assistant' || type === 'system')
|
|
426
|
+
return type;
|
|
427
|
+
const message = objectProp(record, 'message');
|
|
428
|
+
const role = stringProp(message, 'role');
|
|
429
|
+
return role === 'user' || role === 'assistant' || role === 'system' ? role : null;
|
|
430
|
+
}
|
|
431
|
+
function extractCodexMessageText(record) {
|
|
432
|
+
const payload = objectProp(record, 'payload');
|
|
433
|
+
const direct = stringProp(record, 'content') || stringProp(payload, 'message') || stringProp(payload, 'text');
|
|
434
|
+
if (direct)
|
|
435
|
+
return direct;
|
|
436
|
+
return extractTextFromUnknown(objectProp(record, 'message') || objectProp(payload, 'message'));
|
|
437
|
+
}
|
|
438
|
+
function extractClaudeMessageText(record) {
|
|
439
|
+
const message = objectProp(record, 'message');
|
|
440
|
+
return extractTextFromUnknown(rawProp(message, 'content') ?? rawProp(record, 'content'));
|
|
441
|
+
}
|
|
442
|
+
function extractCodexToolEvent(record) {
|
|
443
|
+
const payload = objectProp(record, 'payload');
|
|
444
|
+
const type = stringProp(payload, 'type') || stringProp(record, 'type');
|
|
445
|
+
if (!type || !/(tool|exec|command|patch|call)/i.test(type))
|
|
446
|
+
return null;
|
|
447
|
+
const name = stringProp(payload, 'name') || stringProp(record, 'name') || type;
|
|
448
|
+
const command = stringProp(payload, 'command') || stringProp(payload, 'cmd');
|
|
449
|
+
return truncate(command ? `${name}: ${command}` : name, 300);
|
|
450
|
+
}
|
|
451
|
+
function extractClaudeToolEvent(record) {
|
|
452
|
+
const message = objectProp(record, 'message');
|
|
453
|
+
const content = rawProp(message, 'content') ?? rawProp(record, 'content');
|
|
454
|
+
if (!Array.isArray(content))
|
|
455
|
+
return null;
|
|
456
|
+
const events = [];
|
|
457
|
+
for (const block of content) {
|
|
458
|
+
const type = stringProp(block, 'type');
|
|
459
|
+
if (type !== 'tool_use' && type !== 'tool_result')
|
|
460
|
+
continue;
|
|
461
|
+
const name = stringProp(block, 'name') || type;
|
|
462
|
+
const input = objectProp(block, 'input');
|
|
463
|
+
const command = stringProp(input, 'command') || stringProp(input, 'file_path') || stringProp(input, 'path');
|
|
464
|
+
events.push(truncate(command ? `${name}: ${command}` : name, 300));
|
|
465
|
+
}
|
|
466
|
+
return events.length > 0 ? events.join(' · ') : null;
|
|
467
|
+
}
|
|
468
|
+
function extractTextFromUnknown(value) {
|
|
469
|
+
if (typeof value === 'string')
|
|
470
|
+
return stripMarkup(value).trim();
|
|
471
|
+
if (Array.isArray(value)) {
|
|
472
|
+
return value.map((part) => {
|
|
473
|
+
if (typeof part === 'string')
|
|
474
|
+
return part;
|
|
475
|
+
if (isRecord(part)) {
|
|
476
|
+
if (stringProp(part, 'type') === 'text')
|
|
477
|
+
return stringProp(part, 'text') || '';
|
|
478
|
+
if (stringProp(part, 'type') === 'input_text')
|
|
479
|
+
return stringProp(part, 'text') || '';
|
|
480
|
+
}
|
|
481
|
+
return '';
|
|
482
|
+
}).filter(Boolean).join('\n').trim();
|
|
483
|
+
}
|
|
484
|
+
return '';
|
|
485
|
+
}
|
|
486
|
+
function stripMarkup(text) {
|
|
487
|
+
return text
|
|
488
|
+
.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/giu, '')
|
|
489
|
+
.replace(/<command-name>[\s\S]*?<\/command-name>/giu, '')
|
|
490
|
+
.replace(/<command-message>[\s\S]*?<\/command-message>/giu, '')
|
|
491
|
+
.replace(/<command-args>[\s\S]*?<\/command-args>/giu, '')
|
|
492
|
+
.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/giu, '')
|
|
493
|
+
.trim();
|
|
494
|
+
}
|
|
495
|
+
function isHumanText(text) {
|
|
496
|
+
const trimmed = text.trim();
|
|
497
|
+
return trimmed.length > 0 && !trimmed.startsWith('<system-reminder>') && !trimmed.startsWith('[Request interrupted');
|
|
498
|
+
}
|
|
499
|
+
function cleanSummary(text) {
|
|
500
|
+
return truncate(text.replace(/\s+/g, ' ').trim(), 100);
|
|
501
|
+
}
|
|
502
|
+
function truncate(text, max) {
|
|
503
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
504
|
+
}
|
|
505
|
+
function timestampMs(value) {
|
|
506
|
+
if (!value)
|
|
507
|
+
return undefined;
|
|
508
|
+
const ms = Date.parse(value);
|
|
509
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
510
|
+
}
|
|
511
|
+
function stringProp(value, key) {
|
|
512
|
+
if (!isRecord(value))
|
|
513
|
+
return undefined;
|
|
514
|
+
const prop = value[key];
|
|
515
|
+
return typeof prop === 'string' ? prop : undefined;
|
|
516
|
+
}
|
|
517
|
+
function objectProp(value, key) {
|
|
518
|
+
if (!isRecord(value))
|
|
519
|
+
return undefined;
|
|
520
|
+
const prop = value[key];
|
|
521
|
+
return isRecord(prop) ? prop : undefined;
|
|
522
|
+
}
|
|
523
|
+
function rawProp(value, key) {
|
|
524
|
+
return isRecord(value) ? value[key] : undefined;
|
|
525
|
+
}
|
|
526
|
+
function isRecord(value) {
|
|
527
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
528
|
+
}
|
|
529
|
+
function samePath(a, b) {
|
|
530
|
+
try {
|
|
531
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
return path.resolve(a) === path.resolve(b);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function shortDir(dir) {
|
|
538
|
+
const home = os.homedir();
|
|
539
|
+
const clean = dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
|
|
540
|
+
return clean.length > 40 ? `…${clean.slice(-39)}` : clean;
|
|
541
|
+
}
|
|
542
|
+
function formatRelative(ts) {
|
|
543
|
+
const diff = Math.max(0, Date.now() - ts);
|
|
544
|
+
const min = Math.floor(diff / 60000);
|
|
545
|
+
if (min < 1)
|
|
546
|
+
return 'now';
|
|
547
|
+
if (min < 60)
|
|
548
|
+
return `${min}m ago`;
|
|
549
|
+
const hr = Math.floor(min / 60);
|
|
550
|
+
if (hr < 24)
|
|
551
|
+
return `${hr}h ago`;
|
|
552
|
+
return `${Math.floor(hr / 24)}d ago`;
|
|
553
|
+
}
|
package/dist/stats/tracker.d.ts
CHANGED
|
@@ -48,6 +48,10 @@ export declare function recordUsage(model: string, inputTokens: number, outputTo
|
|
|
48
48
|
export declare function getStatsSummary(): {
|
|
49
49
|
stats: Stats;
|
|
50
50
|
opusCost: number;
|
|
51
|
+
/** All chat / token-billed model spend (excludes image / video / music). */
|
|
52
|
+
chatOnlyCost: number;
|
|
53
|
+
/** Per-image / per-second / per-track media generation spend. */
|
|
54
|
+
mediaCost: number;
|
|
51
55
|
saved: number;
|
|
52
56
|
savedPct: number;
|
|
53
57
|
avgCostPerRequest: number;
|
package/dist/stats/tracker.js
CHANGED
|
@@ -207,10 +207,36 @@ export function recordUsage(model, inputTokens, outputTokens, costUsd, latencyMs
|
|
|
207
207
|
*/
|
|
208
208
|
export function getStatsSummary() {
|
|
209
209
|
const stats = loadStats();
|
|
210
|
-
//
|
|
211
|
-
|
|
210
|
+
// Hypothetical "if you'd used Opus for everything" baseline. Opus is a
|
|
211
|
+
// chat model — it can't replace ImageGen / VideoGen / Music (per_image,
|
|
212
|
+
// per_second, per_track billing), so for those rows the Opus-equivalent
|
|
213
|
+
// cost IS just the actual cost (no alternative). For chat rows, the
|
|
214
|
+
// baseline is the same tokens repriced at Opus rates.
|
|
215
|
+
//
|
|
216
|
+
// Walk byModel: rows with zero tokens are media (recordUsage stores
|
|
217
|
+
// image/video calls with inputTokens=0 outputTokens=0). Those count
|
|
218
|
+
// towards both sides equally; chat rows count at actual price on the
|
|
219
|
+
// "actual" side and at Opus rates on the "baseline" side. Keeping them
|
|
220
|
+
// on both sides means the displayed totals match the user's real
|
|
221
|
+
// spend rather than an unfamiliar chat-only subset.
|
|
222
|
+
let chatOnlyCost = 0;
|
|
223
|
+
let mediaCost = 0;
|
|
224
|
+
for (const m of Object.values(stats.byModel)) {
|
|
225
|
+
if ((m.inputTokens + m.outputTokens) > 0)
|
|
226
|
+
chatOnlyCost += m.costUsd;
|
|
227
|
+
else
|
|
228
|
+
mediaCost += m.costUsd;
|
|
229
|
+
}
|
|
230
|
+
const opusChatCost = (stats.totalInputTokens / 1_000_000) * OPUS_PRICING.input +
|
|
212
231
|
(stats.totalOutputTokens / 1_000_000) * OPUS_PRICING.output;
|
|
213
|
-
|
|
232
|
+
// Display-side baseline: include media on both sides so "you spent X
|
|
233
|
+
// instead of Y" shows real, comparable totals.
|
|
234
|
+
const opusCost = opusChatCost + mediaCost;
|
|
235
|
+
// Saved is the chat-side delta only — media nets to zero. Clamp to 0
|
|
236
|
+
// so a session where the user paid more than Opus-equivalent for chat
|
|
237
|
+
// (e.g. Sonnet 4.6 with extended thinking enabled) doesn't show a
|
|
238
|
+
// negative "savings" number; we just say zero saved.
|
|
239
|
+
const saved = Math.max(0, opusChatCost - chatOnlyCost);
|
|
214
240
|
const savedPct = opusCost > 0 ? (saved / opusCost) * 100 : 0;
|
|
215
241
|
const avgCostPerRequest = stats.totalRequests > 0 ? stats.totalCostUsd / stats.totalRequests : 0;
|
|
216
242
|
// Calculate period
|
|
@@ -224,5 +250,5 @@ export function getStatsSummary() {
|
|
|
224
250
|
else
|
|
225
251
|
period = `${days} days`;
|
|
226
252
|
}
|
|
227
|
-
return { stats, opusCost, saved, savedPct, avgCostPerRequest, period };
|
|
253
|
+
return { stats, opusCost, chatOnlyCost, mediaCost, saved, savedPct, avgCostPerRequest, period };
|
|
228
254
|
}
|
package/dist/ui/app.js
CHANGED
|
@@ -15,6 +15,25 @@ import { estimateCost } from '../pricing.js';
|
|
|
15
15
|
import { formatTokens, shortModelName } from '../stats/format.js';
|
|
16
16
|
import { mouse, forceDisableMouseTracking } from './mouse.js';
|
|
17
17
|
// ─── Full-width input box ──────────────────────────────────────────────────
|
|
18
|
+
const DISABLE_AUTO_WRAP = '\x1b[?7l';
|
|
19
|
+
const ENABLE_AUTO_WRAP = '\x1b[?7h';
|
|
20
|
+
function disableTerminalAutoWrap() {
|
|
21
|
+
if (!process.stdout.isTTY)
|
|
22
|
+
return undefined;
|
|
23
|
+
let restored = false;
|
|
24
|
+
const restore = () => {
|
|
25
|
+
if (restored || !process.stdout.writable)
|
|
26
|
+
return;
|
|
27
|
+
restored = true;
|
|
28
|
+
process.stdout.write(ENABLE_AUTO_WRAP);
|
|
29
|
+
};
|
|
30
|
+
process.stdout.write(DISABLE_AUTO_WRAP);
|
|
31
|
+
process.once('exit', restore);
|
|
32
|
+
return () => {
|
|
33
|
+
process.off('exit', restore);
|
|
34
|
+
restore();
|
|
35
|
+
};
|
|
36
|
+
}
|
|
18
37
|
// Subscribe to terminal resize so React re-renders with fresh dimensions.
|
|
19
38
|
// Without this, useStdout() returns a stable ref and children that read
|
|
20
39
|
// stdout.columns on each render still need React to re-execute them — which
|
|
@@ -48,7 +67,7 @@ function InputBox({ input, setInput, onSubmit, model, balance, chain, walletTail
|
|
|
48
67
|
? `⏎ ${queuedCount ?? 1} queued: ${queued.slice(0, 40)}`
|
|
49
68
|
: 'Working...')
|
|
50
69
|
: 'Type a message...';
|
|
51
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderDimColor: true, paddingX: 1, width: boxWidth, children: [busy && !input ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsx(Box, { flexGrow: 1, children: vimMode ? (_jsx(VimInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false, showMode: true, onModeChange: onVimModeChange })) : (_jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false })) })] }), _jsx(Box, { marginLeft:
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderDimColor: true, paddingX: 1, width: boxWidth, children: [busy && !input ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsx(Box, { flexGrow: 1, children: vimMode ? (_jsx(VimInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false, showMode: true, onModeChange: onVimModeChange })) : (_jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false })) })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', shortModelName(model), " \u00B7 ", balance, chain ? _jsxs(Text, { children: [" \u00B7 ", _jsx(Text, { color: "magenta", children: chain }), walletTail ? _jsxs(Text, { dimColor: true, children: [":", walletTail] }) : ''] }) : '', sessionCost > 0.00001 ? _jsxs(Text, { color: "yellow", children: [" -$", sessionCost.toFixed(4)] }) : '', contextPct !== undefined && contextPct > 0 ? (() => {
|
|
52
71
|
// Visual context bar: ▓▓▓▓▓▓░░░░ 75%
|
|
53
72
|
const filled = Math.round(contextPct / 10);
|
|
54
73
|
const empty = 10 - filled;
|
|
@@ -223,13 +242,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
223
242
|
// Cap at 300 items — older items are already in terminal scrollback
|
|
224
243
|
return next.length > 300 ? next.slice(-300) : next;
|
|
225
244
|
});
|
|
226
|
-
|
|
227
|
-
if (allLines.length > 20) {
|
|
228
|
-
setResponsePreview(' ↑ scroll to see full reply\n' + allLines.slice(-20).join('\n'));
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
setResponsePreview('');
|
|
232
|
-
}
|
|
245
|
+
setResponsePreview('');
|
|
233
246
|
}, []);
|
|
234
247
|
// Permission dialog key handler — captures y/n/a when dialog is visible.
|
|
235
248
|
// ink 6.x: useInput handlers all fire regardless of TextInput focus prop,
|
|
@@ -739,10 +752,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
739
752
|
const isUserMsg = r.key.startsWith('user-');
|
|
740
753
|
return (_jsxs(Box, { flexDirection: "column", children: [!isUserMsg && (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(60) }) })), isUserMsg && (_jsx(Box, { marginTop: 1 })), !isUserMsg && r.thinkMs !== undefined && r.thinkMs >= 500 && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "magenta", dimColor: true, children: ["\u273B Thought for ", (r.thinkMs / 1000).toFixed(1), "s", r.thinkChars && r.thinkChars > 20
|
|
741
754
|
? ` · ~${Math.round(r.thinkChars / 4)} tokens`
|
|
742
|
-
: ''] }) })), _jsx(Box, { paddingLeft: isUserMsg ? 0 : 2, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(r.text) }) }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft:
|
|
755
|
+
: ''] }) })), _jsx(Box, { paddingLeft: isUserMsg ? 0 : 2, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(r.text) }) }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [r.tier && _jsxs(Text, { color: "cyan", children: ["[", r.tier, "] "] }), r.model ? shortModelName(r.model) : '', r.model ? ' · ' : '', r.tokens.calls > 0 && r.tokens.input === 0
|
|
743
756
|
? `${r.tokens.calls} calls`
|
|
744
757
|
: `${formatTokens(r.tokens.input)} in / ${formatTokens(r.tokens.output)} out`, r.cost > 0 ? ` · $${r.cost.toFixed(4)}` : '', r.savings !== undefined && r.savings > 0 ? _jsxs(Text, { color: "green", children: [" saved ", Math.round(r.savings * 100), "%"] }) : ''] }) }))] }, r.key));
|
|
745
|
-
} }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft:
|
|
758
|
+
} }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "yellow", children: "\u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: ["\u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "cyan", children: "\u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: ["\u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => {
|
|
746
759
|
const answer = val.trim() || '(no response)';
|
|
747
760
|
const r = askUserRequest.resolve;
|
|
748
761
|
setAskUserRequest(null);
|
|
@@ -764,8 +777,8 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
764
777
|
return (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: lines.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }));
|
|
765
778
|
})()] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), ' ', _jsxs(Text, { dimColor: true, children: [shortModelName(currentModel), completedTools.length > 0 ? ` · step ${completedTools.length + 1}` : ''] })] }) })), streamText && (() => {
|
|
766
779
|
const { rendered, partial } = renderMarkdownStreaming(streamText);
|
|
767
|
-
return (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsxs(Text, { wrap: "wrap", children: [rendered, rendered && partial ? '\n' : '', partial] }) }));
|
|
768
|
-
})(), responsePreview && !streamText && (_jsx(Box, { flexDirection: "column", marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => {
|
|
780
|
+
return (_jsx(Box, { marginTop: 0, marginBottom: 0, marginLeft: 2, children: _jsxs(Text, { wrap: "wrap", children: [rendered, rendered && partial ? '\n' : '', partial] }) }));
|
|
781
|
+
})(), responsePreview && !streamText && (_jsx(Box, { flexDirection: "column", marginBottom: 0, marginLeft: 2, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => {
|
|
769
782
|
let flatIdx = 0;
|
|
770
783
|
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "Select a model " }), _jsx(Text, { dimColor: true, children: "(\u2191\u2193 navigate, Enter select, Esc cancel)" })] }), PICKER_CATEGORIES.map((cat) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2500\u2500 ", cat.category, " \u2500\u2500"] }) }), cat.models.map((m) => {
|
|
771
784
|
const myIdx = flatIdx++;
|
|
@@ -781,6 +794,7 @@ export function launchInkUI(opts) {
|
|
|
781
794
|
let pendingInput = null; // Queue for inputs that arrive before waitForInput
|
|
782
795
|
let exiting = false;
|
|
783
796
|
let abortCallback = null;
|
|
797
|
+
const restoreTerminalAutoWrap = disableTerminalAutoWrap();
|
|
784
798
|
const instance = render(_jsx(RunCodeApp, { initialModel: opts.model, workDir: opts.workDir, walletAddress: opts.walletAddress || 'not set — run: franklin setup', walletBalance: opts.walletBalance || 'unknown', chain: opts.chain || 'base', startWithPicker: opts.showPicker, onSubmit: (value) => {
|
|
785
799
|
if (resolveInput) {
|
|
786
800
|
resolveInput(value);
|
|
@@ -826,7 +840,11 @@ export function launchInkUI(opts) {
|
|
|
826
840
|
return new Promise((resolve) => { resolveInput = resolve; });
|
|
827
841
|
},
|
|
828
842
|
onAbort: (cb) => { abortCallback = cb; },
|
|
829
|
-
cleanup: () => {
|
|
843
|
+
cleanup: () => {
|
|
844
|
+
mouse.disable();
|
|
845
|
+
instance.unmount();
|
|
846
|
+
restoreTerminalAutoWrap?.();
|
|
847
|
+
},
|
|
830
848
|
requestPermission: (toolName, description) => {
|
|
831
849
|
const ui = globalThis.__franklin_ui;
|
|
832
850
|
return ui?.requestPermission(toolName, description) ?? Promise.resolve('no');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockrun/franklin",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.4",
|
|
4
4
|
"description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"node": ">=20"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@blockrun/llm": "^1.
|
|
69
|
+
"@blockrun/llm": "^1.13.0",
|
|
70
70
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
71
71
|
"@solana/spl-token": "^0.4.14",
|
|
72
72
|
"@solana/web3.js": "^1.98.4",
|