@blockrun/franklin 3.8.31 → 3.8.33

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.
@@ -68,7 +68,7 @@ export function classifyAgentError(message) {
68
68
  'deadline exceeded',
69
69
  ])) {
70
70
  return {
71
- category: 'timeout', label: 'Timeout', isTransient: true,
71
+ category: 'timeout', label: 'Timeout', isTransient: true, maxRetries: 1,
72
72
  suggestion: 'Check your network connection. Use /retry to try again.',
73
73
  };
74
74
  }
package/dist/agent/llm.js CHANGED
@@ -5,7 +5,50 @@
5
5
  */
6
6
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
7
7
  import { USER_AGENT } from '../config.js';
8
+ import { routeRequest, parseRoutingProfile } from '../router/index.js';
8
9
  import { ThinkTagStripper } from './think-tag-stripper.js';
10
+ function getModelIdleTimeoutMs() {
11
+ const raw = process.env.FRANKLIN_MODEL_IDLE_TIMEOUT_MS;
12
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
13
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 25_000;
14
+ }
15
+ function linkAbortSignal(parent, child) {
16
+ if (!parent)
17
+ return () => { };
18
+ if (parent.aborted) {
19
+ child.abort(parent.reason);
20
+ return () => { };
21
+ }
22
+ const forward = () => child.abort(parent.reason);
23
+ parent.addEventListener('abort', forward, { once: true });
24
+ return () => parent.removeEventListener('abort', forward);
25
+ }
26
+ function createModelTimeoutError(stage, model, timeoutMs) {
27
+ return new Error(`Model ${stage} timed out after ${timeoutMs}ms on ${model}`);
28
+ }
29
+ async function withAbortableTimeout(work, controller, timeoutError, timeoutMs) {
30
+ if (timeoutMs <= 0)
31
+ return work();
32
+ let timer;
33
+ try {
34
+ return await Promise.race([
35
+ work(),
36
+ new Promise((_, reject) => {
37
+ timer = setTimeout(() => {
38
+ try {
39
+ controller.abort(timeoutError);
40
+ }
41
+ catch { /* ignore */ }
42
+ reject(timeoutError);
43
+ }, timeoutMs);
44
+ }),
45
+ ]);
46
+ }
47
+ finally {
48
+ if (timer)
49
+ clearTimeout(timer);
50
+ }
51
+ }
9
52
  /**
10
53
  * Extract the most human-readable message from an error body.
11
54
  * Some gateways wrap provider errors multiple times, e.g.
@@ -188,9 +231,7 @@ export class ModelClient {
188
231
  resolveVirtualModel(model) {
189
232
  if (!model.startsWith('blockrun/'))
190
233
  return model;
191
- // Import router dynamically to avoid circular deps
192
234
  try {
193
- const { routeRequest, parseRoutingProfile } = require('../router/index.js');
194
235
  const profile = parseRoutingProfile(model);
195
236
  if (profile) {
196
237
  const result = routeRequest('', profile);
@@ -288,39 +329,47 @@ export class ModelClient {
288
329
  if (this.debug) {
289
330
  console.error(`[franklin] POST ${endpoint} model=${request.model}`);
290
331
  }
291
- let response = await fetch(endpoint, {
292
- method: 'POST',
293
- headers,
294
- body,
295
- signal,
296
- });
297
- // Handle x402 payment
298
- if (response.status === 402) {
299
- if (this.debug)
300
- console.error('[franklin] Payment required — signing...');
301
- const paymentHeader = await this.signPayment(response);
302
- if (!paymentHeader) {
303
- yield { kind: 'error', payload: { message: 'Payment signing failed' } };
304
- return;
305
- }
306
- response = await fetch(endpoint, {
332
+ const timeoutMs = getModelIdleTimeoutMs();
333
+ const requestController = new AbortController();
334
+ const unlinkAbort = linkAbortSignal(signal, requestController);
335
+ try {
336
+ let response = await withAbortableTimeout(() => fetch(endpoint, {
307
337
  method: 'POST',
308
- headers: { ...headers, ...paymentHeader },
338
+ headers,
309
339
  body,
310
- signal,
311
- });
340
+ signal: requestController.signal,
341
+ }), requestController, createModelTimeoutError('request', request.model, timeoutMs), timeoutMs);
342
+ // Handle x402 payment
343
+ if (response.status === 402) {
344
+ if (this.debug)
345
+ console.error('[franklin] Payment required — signing...');
346
+ const paymentHeader = await this.signPayment(response);
347
+ if (!paymentHeader) {
348
+ yield { kind: 'error', payload: { message: 'Payment signing failed' } };
349
+ return;
350
+ }
351
+ response = await withAbortableTimeout(() => fetch(endpoint, {
352
+ method: 'POST',
353
+ headers: { ...headers, ...paymentHeader },
354
+ body,
355
+ signal: requestController.signal,
356
+ }), requestController, createModelTimeoutError('request', request.model, timeoutMs), timeoutMs);
357
+ }
358
+ if (!response.ok) {
359
+ const errorBody = await response.text().catch(() => 'unknown error');
360
+ const message = extractApiErrorMessage(errorBody);
361
+ yield {
362
+ kind: 'error',
363
+ payload: { status: response.status, message },
364
+ };
365
+ return;
366
+ }
367
+ // Parse SSE stream
368
+ yield* this.parseSSEStream(response, requestController, timeoutMs, request.model);
312
369
  }
313
- if (!response.ok) {
314
- const errorBody = await response.text().catch(() => 'unknown error');
315
- const message = extractApiErrorMessage(errorBody);
316
- yield {
317
- kind: 'error',
318
- payload: { status: response.status, message },
319
- };
320
- return;
370
+ finally {
371
+ unlinkAbort();
321
372
  }
322
- // Parse SSE stream
323
- yield* this.parseSSEStream(response, signal);
324
373
  }
325
374
  /**
326
375
  * Non-streaming completion for simple requests.
@@ -617,7 +666,7 @@ export class ModelClient {
617
666
  return header;
618
667
  }
619
668
  // ─── SSE Parsing ───────────────────────────────────────────────────────
620
- async *parseSSEStream(response, signal) {
669
+ async *parseSSEStream(response, controller, timeoutMs, model) {
621
670
  const reader = response.body?.getReader();
622
671
  if (!reader) {
623
672
  yield { kind: 'error', payload: { message: 'No response body' } };
@@ -630,9 +679,9 @@ export class ModelClient {
630
679
  const MAX_BUFFER = 1_000_000; // 1MB buffer cap
631
680
  try {
632
681
  while (true) {
633
- if (signal?.aborted)
682
+ if (controller.signal.aborted)
634
683
  break;
635
- const { done, value } = await reader.read();
684
+ const { done, value } = await withAbortableTimeout(() => reader.read(), controller, createModelTimeoutError('stream', model, timeoutMs), timeoutMs);
636
685
  if (done)
637
686
  break;
638
687
  buffer += decoder.decode(value, { stream: true });
@@ -34,7 +34,6 @@ function isRunning(pid) {
34
34
  }
35
35
  catch { /* fall through to ps */ }
36
36
  try {
37
- const { execSync } = require('node:child_process');
38
37
  const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
39
38
  return /franklin|runcode|node.*dist\/index/.test(cmd);
40
39
  }
@@ -11,9 +11,9 @@ import { listSessions, getSessionFilePath } from './storage.js';
11
11
  function tokenize(text) {
12
12
  return text
13
13
  .toLowerCase()
14
- .replace(/[^\w\s]/g, ' ')
15
- .split(/\s+/)
16
- .filter(t => t.length > 1);
14
+ .replace(/[^\p{L}\p{N}_\s]/gu, ' ')
15
+ .split(/\s+/u)
16
+ .filter(t => t.length > 1 || /[^\x00-\x7F]/.test(t));
17
17
  }
18
18
  function parseQuery(query) {
19
19
  const phrases = [];
@@ -158,10 +158,7 @@ export function loadSessionHistory(sessionId) {
158
158
  return [];
159
159
  }
160
160
  }
161
- /**
162
- * List all saved sessions, newest first.
163
- */
164
- export function listSessions() {
161
+ function readSessionMetas(includeGhosts = false) {
165
162
  const sessionsDir = getSessionsDir();
166
163
  try {
167
164
  const files = fs.readdirSync(sessionsDir)
@@ -174,14 +171,19 @@ export function listSessions() {
174
171
  }
175
172
  catch { /* skip corrupted */ }
176
173
  }
177
- // Filter out ghost sessions (0 messages)
178
- const filtered = metas.filter(m => m.messageCount > 0);
179
- return filtered.sort((a, b) => b.updatedAt - a.updatedAt);
174
+ const visible = includeGhosts ? metas : metas.filter(m => m.messageCount > 0);
175
+ return visible.sort((a, b) => b.updatedAt - a.updatedAt);
180
176
  }
181
177
  catch {
182
178
  return [];
183
179
  }
184
180
  }
181
+ /**
182
+ * List all saved sessions, newest first.
183
+ */
184
+ export function listSessions() {
185
+ return readSessionMetas(false);
186
+ }
185
187
  /**
186
188
  * Find the latest saved session tagged with a given channel (e.g.
187
189
  * `telegram:12345`). Used by non-CLI drivers to resume across process
@@ -198,25 +200,26 @@ export function findLatestSessionByChannel(channel) {
198
200
  * Accepts optional activeSessionId to protect from deletion.
199
201
  */
200
202
  export function pruneOldSessions(activeSessionId) {
201
- const sessions = listSessions();
202
- if (sessions.length <= MAX_SESSIONS)
203
- return;
204
- const toDelete = sessions
205
- .slice(MAX_SESSIONS)
206
- .filter(s => s.id !== activeSessionId); // Never delete active session
207
- for (const s of toDelete) {
208
- try {
209
- fs.unlinkSync(sessionPath(s.id));
210
- }
211
- catch { /* ok */ }
212
- try {
213
- fs.unlinkSync(metaPath(s.id));
203
+ const sessions = readSessionMetas(false);
204
+ const allSessions = readSessionMetas(true);
205
+ if (sessions.length > MAX_SESSIONS) {
206
+ const toDelete = sessions
207
+ .slice(MAX_SESSIONS)
208
+ .filter(s => s.id !== activeSessionId); // Never delete active session
209
+ for (const s of toDelete) {
210
+ try {
211
+ fs.unlinkSync(sessionPath(s.id));
212
+ }
213
+ catch { /* ok */ }
214
+ try {
215
+ fs.unlinkSync(metaPath(s.id));
216
+ }
217
+ catch { /* ok */ }
214
218
  }
215
- catch { /* ok */ }
216
219
  }
217
220
  // Also clean up ghost sessions (0 messages, older than 5 minutes)
218
221
  const fiveMinAgo = Date.now() - 5 * 60 * 1000;
219
- for (const s of sessions) {
222
+ for (const s of allSessions) {
220
223
  if (s.id === activeSessionId)
221
224
  continue;
222
225
  if (s.messageCount === 0 && s.createdAt < fiveMinAgo) {
@@ -20,7 +20,7 @@
20
20
  * files — telemetry is just a stable, aggregation-friendly view of
21
21
  * information the user already has.
22
22
  */
23
- import type { SessionMeta } from '../session/storage.js';
23
+ import { type SessionMeta } from '../session/storage.js';
24
24
  interface ConsentRecord {
25
25
  enabled: boolean;
26
26
  enabledAt?: number;
@@ -24,6 +24,7 @@ import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import crypto from 'node:crypto';
26
26
  import { BLOCKRUN_DIR, VERSION } from '../config.js';
27
+ import { listSessions } from '../session/storage.js';
27
28
  const CONSENT_FILE = path.join(BLOCKRUN_DIR, 'telemetry-consent.json');
28
29
  const LOG_FILE = path.join(BLOCKRUN_DIR, 'telemetry.jsonl');
29
30
  const INSTALL_ID_FILE = path.join(BLOCKRUN_DIR, 'telemetry-install-id.txt');
@@ -120,11 +121,6 @@ export function recordSession(meta, chain) {
120
121
  export function recordLatestSessionIfEnabled(workingDir, chain) {
121
122
  if (!isTelemetryEnabled())
122
123
  return;
123
- // Lazy import to avoid a circular session/storage <-> telemetry dependency.
124
- // Using require() here keeps this module synchronous for tests.
125
- /* eslint-disable @typescript-eslint/no-require-imports */
126
- const { listSessions } = require('../session/storage.js');
127
- /* eslint-enable @typescript-eslint/no-require-imports */
128
124
  const sessions = listSessions();
129
125
  const match = sessions.find(s => s.workDir === workingDir);
130
126
  if (!match)
package/dist/ui/app.js CHANGED
@@ -333,6 +333,18 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
333
333
  const trimmed = value.trim();
334
334
  if (!trimmed)
335
335
  return;
336
+ // Exit commands bypass the busy-queue gate — if the user wants out,
337
+ // they want out immediately, not after whatever turn is in flight.
338
+ // Covers bare 'exit' / 'quit' / 'q' and slash-prefixed /exit / /quit.
339
+ const lower = trimmed.toLowerCase();
340
+ const isExit = lower === 'exit' || lower === 'quit' || lower === 'q' ||
341
+ lower === '/exit' || lower === '/quit';
342
+ if (isExit) {
343
+ onAbort();
344
+ onExit();
345
+ exit();
346
+ return;
347
+ }
336
348
  // If agent is busy, queue the message — it will be auto-submitted when the turn finishes
337
349
  if (!ready) {
338
350
  setQueuedInputs(prev => [...prev, trimmed]);
@@ -340,13 +352,6 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
340
352
  showStatus(`Queued message (${queuedInputsRef.current.length + 1} pending)`, 'warning', 1500);
341
353
  return;
342
354
  }
343
- // Bare exit/quit (no slash needed)
344
- const lower = trimmed.toLowerCase();
345
- if (lower === 'exit' || lower === 'quit' || lower === 'q') {
346
- onExit();
347
- exit();
348
- return;
349
- }
350
355
  // ── Slash commands ──
351
356
  if (trimmed.startsWith('/')) {
352
357
  setInput('');
@@ -355,11 +360,8 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
355
360
  const parts = trimmed.split(/\s+/);
356
361
  const cmd = parts[0].toLowerCase();
357
362
  switch (cmd) {
358
- case '/exit':
359
- case '/quit':
360
- onExit();
361
- exit();
362
- return;
363
+ // /exit and /quit are handled earlier (before the busy-queue gate)
364
+ // so they exit the session immediately even mid-turn.
363
365
  case '/model':
364
366
  case '/models':
365
367
  if (parts[1]) {
@@ -614,7 +616,16 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
614
616
  break;
615
617
  }
616
618
  case 'usage': {
617
- setCurrentModel(event.model);
619
+ // DO NOT setCurrentModel(event.model) here. currentModel
620
+ // represents the user's selection (e.g. 'blockrun/auto'),
621
+ // not what the router resolved for this specific turn. The
622
+ // per-turn resolved model is already captured in
623
+ // turnModelRef (rendered in the turn-summary line below
624
+ // each response) and in onModelChange('system') when the
625
+ // loop itself decides to swap (empty-response / 402 fallback).
626
+ // Overriding currentModel from every usage event made the
627
+ // status bar permanently show the last resolved model and
628
+ // create a false impression that auto mode was stuck.
618
629
  setTurnTokens(prev => ({
619
630
  input: prev.input + event.inputTokens,
620
631
  output: prev.output + event.outputTokens,
@@ -3,6 +3,15 @@
3
3
  * Lists recent sessions (newest first) and returns the selected ID.
4
4
  */
5
5
  import { type SessionMeta } from '../session/storage.js';
6
+ type SessionPickerSelection = {
7
+ kind: 'cancel';
8
+ } | {
9
+ kind: 'selected';
10
+ id: string;
11
+ } | {
12
+ kind: 'invalid';
13
+ message: string;
14
+ };
6
15
  /**
7
16
  * Resolve a user-provided session identifier to a full session ID.
8
17
  * Supports exact match and unambiguous prefix match (minimum 8 chars).
@@ -16,6 +25,7 @@ export declare function resolveSessionIdInput(input: string): {
16
25
  error: 'not-found' | 'ambiguous';
17
26
  candidates: SessionMeta[];
18
27
  };
28
+ export declare function resolvePickerSelection(input: string, shown: SessionMeta[], sessions: SessionMeta[]): SessionPickerSelection;
19
29
  /**
20
30
  * Find the most recent session for a given working directory.
21
31
  * Returns null if none exists.
@@ -28,3 +38,4 @@ export declare function findLatestSessionForDir(workDir: string): SessionMeta |
28
38
  export declare function pickSession(opts?: {
29
39
  workDir?: string;
30
40
  }): Promise<string | null>;
41
+ export {};
@@ -46,7 +46,9 @@ function modelShort(model) {
46
46
  * Returns { ok, id } on success, or { ok, error, candidates } on failure.
47
47
  */
48
48
  export function resolveSessionIdInput(input) {
49
- const sessions = listSessions();
49
+ return resolveSessionIdFromList(input, listSessions());
50
+ }
51
+ function resolveSessionIdFromList(input, sessions) {
50
52
  // Exact match first
51
53
  const exact = sessions.find((s) => s.id === input);
52
54
  if (exact)
@@ -61,6 +63,33 @@ export function resolveSessionIdInput(input) {
61
63
  }
62
64
  return { ok: false, error: 'not-found', candidates: [] };
63
65
  }
66
+ export function resolvePickerSelection(input, shown, sessions) {
67
+ const trimmed = input.trim();
68
+ if (!trimmed)
69
+ return { kind: 'cancel' };
70
+ const num = parseInt(trimmed, 10);
71
+ if (!isNaN(num) && num >= 1 && num <= shown.length) {
72
+ return { kind: 'selected', id: shown[num - 1].id };
73
+ }
74
+ const exact = sessions.find((s) => s.id === trimmed);
75
+ if (exact)
76
+ return { kind: 'selected', id: exact.id };
77
+ const resolved = resolveSessionIdFromList(trimmed, sessions);
78
+ if (resolved.ok) {
79
+ return { kind: 'selected', id: resolved.id };
80
+ }
81
+ if (resolved.error === 'ambiguous') {
82
+ const preview = resolved.candidates
83
+ .slice(0, 3)
84
+ .map((candidate) => candidate.id)
85
+ .join(', ');
86
+ return {
87
+ kind: 'invalid',
88
+ message: `Ambiguous session prefix: ${trimmed}${preview ? ` (${preview}${resolved.candidates.length > 3 ? ', …' : ''})` : ''}`,
89
+ };
90
+ }
91
+ return { kind: 'invalid', message: `No session found matching: ${trimmed}` };
92
+ }
64
93
  /**
65
94
  * Find the most recent session for a given working directory.
66
95
  * Returns null if none exists.
@@ -109,21 +138,23 @@ export async function pickSession(opts = {}) {
109
138
  terminal: process.stdin.isTTY ?? false,
110
139
  });
111
140
  return new Promise((resolve) => {
112
- rl.question(chalk.bold(' session> '), (answer) => {
113
- rl.close();
114
- const trimmed = answer.trim();
115
- if (!trimmed) {
116
- resolve(null);
117
- return;
118
- }
119
- const num = parseInt(trimmed, 10);
120
- if (!isNaN(num) && num >= 1 && num <= shown.length) {
121
- resolve(shown[num - 1].id);
122
- return;
123
- }
124
- // Allow raw ID as well
125
- const match = sessions.find(s => s.id === trimmed || s.id.startsWith(trimmed));
126
- resolve(match ? match.id : null);
127
- });
141
+ const prompt = () => {
142
+ rl.question(chalk.bold(' session> '), (answer) => {
143
+ const selection = resolvePickerSelection(answer, shown, sessions);
144
+ if (selection.kind === 'cancel') {
145
+ rl.close();
146
+ resolve(null);
147
+ return;
148
+ }
149
+ if (selection.kind === 'selected') {
150
+ rl.close();
151
+ resolve(selection.id);
152
+ return;
153
+ }
154
+ console.error(chalk.yellow(` ${selection.message}`));
155
+ prompt();
156
+ });
157
+ };
158
+ prompt();
128
159
  });
129
160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.31",
3
+ "version": "3.8.33",
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": {