@blockrun/franklin 3.8.9 → 3.8.11

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.
@@ -96,6 +96,7 @@ export function classifyAgentError(message) {
96
96
  'workers are busy',
97
97
  'all workers are busy',
98
98
  'server busy',
99
+ 'high demand',
99
100
  'capacity',
100
101
  ])) {
101
102
  return {
@@ -27,6 +27,13 @@ export interface LLMClientOptions {
27
27
  chain: Chain;
28
28
  debug?: boolean;
29
29
  }
30
+ /**
31
+ * Extract the most human-readable message from an error body.
32
+ * Some gateways wrap provider errors multiple times, e.g.
33
+ * `{"error":{"message":"{\"error\":{\"message\":\"...\"}}"}}`.
34
+ * Peel those layers so the UI doesn't show raw nested JSON.
35
+ */
36
+ export declare function extractApiErrorMessage(errorBody: string): string;
30
37
  /**
31
38
  * Apply Anthropic prompt caching using the `system_and_3` strategy.
32
39
  * Pattern from nousresearch/hermes-agent `agent/prompt_caching.py`.
package/dist/agent/llm.js CHANGED
@@ -6,6 +6,53 @@
6
6
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
7
7
  import { USER_AGENT } from '../config.js';
8
8
  import { ThinkTagStripper } from './think-tag-stripper.js';
9
+ /**
10
+ * Extract the most human-readable message from an error body.
11
+ * Some gateways wrap provider errors multiple times, e.g.
12
+ * `{"error":{"message":"{\"error\":{\"message\":\"...\"}}"}}`.
13
+ * Peel those layers so the UI doesn't show raw nested JSON.
14
+ */
15
+ export function extractApiErrorMessage(errorBody) {
16
+ const visited = new Set();
17
+ const walk = (value, depth = 0) => {
18
+ // Some providers wrap the real message under error.message as a JSON
19
+ // string, which adds another object/string hop. Allow a few layers of
20
+ // nesting without risking runaway recursion.
21
+ if (depth > 8 || visited.has(value))
22
+ return null;
23
+ if (value && (typeof value === 'object' || typeof value === 'string')) {
24
+ visited.add(value);
25
+ }
26
+ if (typeof value === 'string') {
27
+ const trimmed = value.trim();
28
+ if (trimmed) {
29
+ try {
30
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
31
+ const parsed = JSON.parse(trimmed);
32
+ const nested = walk(parsed, depth + 1);
33
+ if (nested)
34
+ return nested;
35
+ }
36
+ }
37
+ catch { /* plain string — use as-is below */ }
38
+ }
39
+ return trimmed || null;
40
+ }
41
+ if (!value || typeof value !== 'object')
42
+ return null;
43
+ const obj = value;
44
+ for (const key of ['error', 'message', 'detail', 'reason']) {
45
+ if (key in obj) {
46
+ const nested = walk(obj[key], depth + 1);
47
+ if (nested)
48
+ return nested;
49
+ }
50
+ }
51
+ return null;
52
+ };
53
+ const extracted = walk(errorBody) ?? errorBody;
54
+ return extracted.replace(/\s+/g, ' ').trim();
55
+ }
9
56
  // ─── Anthropic Prompt Caching ─────────────────────────────────────────────
10
57
  /**
11
58
  * Apply Anthropic prompt caching using the `system_and_3` strategy.
@@ -265,13 +312,7 @@ export class ModelClient {
265
312
  }
266
313
  if (!response.ok) {
267
314
  const errorBody = await response.text().catch(() => 'unknown error');
268
- // Extract human-readable message from JSON error bodies ({"error":{"message":"..."}})
269
- let message = errorBody;
270
- try {
271
- const parsed = JSON.parse(errorBody);
272
- message = parsed?.error?.message || parsed?.message || errorBody;
273
- }
274
- catch { /* not JSON — use raw text */ }
315
+ const message = extractApiErrorMessage(errorBody);
275
316
  yield {
276
317
  kind: 'error',
277
318
  payload: { status: response.status, message },
@@ -346,6 +346,9 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
346
346
  activeTools.add(activateToolCap.spec.name);
347
347
  const allToolDefs = [...capabilityMap.values()].map(c => c.spec);
348
348
  const buildCallToolDefs = () => dynamicTools ? allToolDefs.filter(t => activeTools.has(t.name)) : allToolDefs;
349
+ const buildActiveCapabilityMap = () => dynamicTools
350
+ ? new Map([...capabilityMap.entries()].filter(([name]) => activeTools.has(name)))
351
+ : capabilityMap;
349
352
  const maxTurns = config.maxTurns ?? 15;
350
353
  const workDir = config.workingDir ?? process.cwd();
351
354
  const permissions = new PermissionManager(config.permissionMode ?? 'default', config.permissionPromptFn);
@@ -649,8 +652,9 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
649
652
  let usage;
650
653
  let stopReason;
651
654
  // Create streaming executor for concurrent tool execution
655
+ const activeCapabilityMap = buildActiveCapabilityMap();
652
656
  const streamExec = new StreamingExecutor({
653
- handlers: capabilityMap,
657
+ handlers: activeCapabilityMap,
654
658
  scope: {
655
659
  workingDir: workDir,
656
660
  abortSignal: abort.signal,
package/dist/banner.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { kickoffVersionCheck, getAvailableUpdate } from './version-check.js';
2
3
  // ─── Ben Franklin portrait ─────────────────────────────────────────────────
3
4
  //
4
5
  // Generated once, at build time, from the Joseph Duplessis 1785 oil painting
@@ -82,6 +83,38 @@ function padVisible(s, targetWidth) {
82
83
  return s + '\x1b[0m' + ' '.repeat(targetWidth - current);
83
84
  }
84
85
  export function printBanner(version) {
86
+ const style = process.env.FRANKLIN_BANNER?.toLowerCase();
87
+ if (style === 'full' || style === 'legacy') {
88
+ printLegacyBanner(version);
89
+ }
90
+ else {
91
+ printCompactBanner(version);
92
+ }
93
+ // Kick off a background refresh for *next* startup, and print a hint now
94
+ // if the cache already knows about a newer version. All wrapped in
95
+ // try/catch because a banner should never be the reason startup breaks.
96
+ try {
97
+ kickoffVersionCheck();
98
+ const update = getAvailableUpdate();
99
+ if (update) {
100
+ console.log(chalk.yellow('⟳ ') +
101
+ chalk.bold(`Franklin ${update.latest}`) +
102
+ chalk.dim(` available — you have ${update.current}`));
103
+ console.log(chalk.dim(' Run: ') +
104
+ chalk.bold('npm install -g @blockrun/franklin@latest'));
105
+ console.log('');
106
+ }
107
+ }
108
+ catch { /* version-check is best-effort; never block startup */ }
109
+ }
110
+ function printCompactBanner(version) {
111
+ const title = chalk.bold.hex(GOLD_START)('FRANKLIN');
112
+ const meta = chalk.dim(` · blockrun.ai · v${version}`);
113
+ console.log(`${title}${meta}`);
114
+ console.log(chalk.dim('The AI agent with a wallet'));
115
+ console.log('');
116
+ }
117
+ function printLegacyBanner(version) {
85
118
  const termWidth = process.stdout.columns ?? 80;
86
119
  const useSideBySide = termWidth >= MIN_WIDTH_FOR_PORTRAIT;
87
120
  if (useSideBySide) {
@@ -17,6 +17,7 @@ import os from 'node:os';
17
17
  import { setupAgentWallet, setupAgentSolanaWallet, } from '@blockrun/llm';
18
18
  import { loadChain, API_URLS, VERSION, BLOCKRUN_DIR } from '../config.js';
19
19
  import { isTelemetryEnabled, readAllRecords } from '../telemetry/store.js';
20
+ import { getAvailableUpdate, kickoffVersionCheck } from '../version-check.js';
20
21
  async function runChecks() {
21
22
  const out = [];
22
23
  // ── 1. Runtime ────────────────────────────────────────────────────
@@ -29,10 +30,17 @@ async function runChecks() {
29
30
  remedy: nodeMajor >= 20 ? undefined : 'Upgrade Node.js: https://nodejs.org',
30
31
  });
31
32
  // ── 2. Franklin version ───────────────────────────────────────────
33
+ // Kick the daily cache refresh so subsequent doctor runs carry fresh
34
+ // data. Current run uses whatever's already cached.
35
+ kickoffVersionCheck();
36
+ const update = getAvailableUpdate();
32
37
  out.push({
33
38
  name: 'Franklin',
34
- status: 'ok',
35
- detail: `v${VERSION}`,
39
+ status: update ? 'warn' : 'ok',
40
+ detail: update
41
+ ? `v${VERSION} — update available: v${update.latest}`
42
+ : `v${VERSION}`,
43
+ remedy: update ? 'npm install -g @blockrun/franklin@latest' : undefined,
36
44
  });
37
45
  // ── 3. BLOCKRUN_DIR writable ──────────────────────────────────────
38
46
  try {
package/dist/index.js CHANGED
@@ -265,7 +265,7 @@ if (firstArg === 'solana' || firstArg === 'base') {
265
265
  saveChain(firstArg);
266
266
  const startOpts = parseStartFlags(args, 1);
267
267
  await startCommand(startOpts);
268
- process.exit(0);
268
+ process.exit(process.exitCode ?? 0);
269
269
  }
270
270
  else if (!firstArg || firstArg.startsWith('-')) {
271
271
  if (hasAnyFlag(args, HELP_FLAGS) && hasStartOnlyFlag(args)) {
@@ -281,7 +281,7 @@ else if (!firstArg || firstArg.startsWith('-')) {
281
281
  // No subcommand or only flags — treat as 'start' with flags
282
282
  const startOpts = parseStartFlags(args, 0);
283
283
  await startCommand(startOpts);
284
- process.exit(0);
284
+ process.exit(process.exitCode ?? 0);
285
285
  }
286
286
  else {
287
287
  program.parse();
package/dist/ui/app.js CHANGED
@@ -39,12 +39,16 @@ function useTerminalSize() {
39
39
  }
40
40
  function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, queuedCount, focused, busy, contextPct, vimMode, onVimModeChange }) {
41
41
  const { cols } = useTerminalSize();
42
+ // Avoid drawing right up to the terminal edge. Several terminals auto-wrap
43
+ // a full-width border glyph onto the next row, which leaves "ghost" top
44
+ // borders behind on re-render after errors / status changes.
45
+ const boxWidth = Math.max(20, cols - 2);
42
46
  const placeholder = busy
43
47
  ? (queued
44
48
  ? `⏎ ${queuedCount ?? 1} queued: ${queued.slice(0, 40)}`
45
49
  : 'Working...')
46
50
  : 'Type a message...';
47
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderDimColor: true, paddingX: 1, width: cols, 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: 1, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', shortModelName(model), " \u00B7 ", balance, sessionCost > 0.00001 ? _jsxs(Text, { color: "yellow", children: [" -$", sessionCost.toFixed(4)] }) : '', contextPct !== undefined && contextPct > 0 ? (() => {
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: 1, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', shortModelName(model), " \u00B7 ", balance, sessionCost > 0.00001 ? _jsxs(Text, { color: "yellow", children: [" -$", sessionCost.toFixed(4)] }) : '', contextPct !== undefined && contextPct > 0 ? (() => {
48
52
  // Visual context bar: ▓▓▓▓▓▓░░░░ 75%
49
53
  const filled = Math.round(contextPct / 10);
50
54
  const empty = 10 - filled;
@@ -52,6 +56,28 @@ function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queu
52
56
  return (_jsxs(Text, { children: [' ', _jsx(Text, { color: barColor, children: '▓'.repeat(filled) }), _jsx(Text, { dimColor: true, children: '░'.repeat(empty) }), _jsxs(Text, { color: barColor, children: [' ', contextPct, "%"] })] }));
53
57
  })() : null, (queuedCount ?? 0) > 0 ? _jsxs(Text, { color: "cyan", children: [" \u00B7 ", queuedCount, " queued"] }) : null, ' · esc'] }) })] }));
54
58
  }
59
+ function formatAgentErrorForDisplay(error) {
60
+ const lines = error.split('\n').map((line) => line.trim()).filter(Boolean);
61
+ const tipIndex = lines.findIndex((line) => /^tip:/i.test(line));
62
+ const mainLines = tipIndex >= 0 ? lines.slice(0, tipIndex) : lines;
63
+ const tipLines = tipIndex >= 0 ? lines.slice(tipIndex) : [];
64
+ let main = mainLines.join(' ').replace(/\s+/g, ' ').trim();
65
+ let tip = tipLines.join(' ').replace(/\s+/g, ' ').trim();
66
+ const labelMatch = /^\[([^\]]+)\]\s*/.exec(main);
67
+ const label = labelMatch?.[1];
68
+ if (labelMatch)
69
+ main = main.slice(labelMatch[0].length).trim();
70
+ if (tip)
71
+ tip = tip.replace(/^tip:\s*/i, '');
72
+ const out = ['**Request failed**'];
73
+ if (label)
74
+ out.push(`- Type: ${label}`);
75
+ if (main)
76
+ out.push(`- Message: ${main}`);
77
+ if (tip)
78
+ out.push(`- Tip: ${tip}`);
79
+ return out.join('\n');
80
+ }
55
81
  function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain, startWithPicker, onSubmit, onModelChange, onAbort, onExit, }) {
56
82
  const { exit } = useApp();
57
83
  const [input, setInput] = useState('');
@@ -635,7 +661,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
635
661
  setStreamText('');
636
662
  }
637
663
  if (event.reason === 'error' && event.error) {
638
- commitResponse(`Error: ${event.error}`, turnTokensRef.current, turnCostRef.current);
664
+ commitResponse(formatAgentErrorForDisplay(event.error), turnTokensRef.current, turnCostRef.current);
639
665
  showStatus('Turn failed', 'error', 5000);
640
666
  }
641
667
  else if (event.reason === 'aborted') {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Update-check utility.
3
+ *
4
+ * Quietly pings npm once per day, caches the latest published version in
5
+ * `~/.blockrun/version-check.json`, and exposes a sync helper the CLI
6
+ * uses to nudge users when they're behind. The check is non-blocking:
7
+ * fire-and-forget at startup, render the notice on the *next* run if the
8
+ * network was slow the first time. Users never wait on it.
9
+ *
10
+ * Respects two opt-outs:
11
+ * - `FRANKLIN_NO_UPDATE_CHECK=1` — explicit user preference
12
+ * - CI-like environments (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, etc.)
13
+ *
14
+ * Cache format is intentionally small and forward-compatible: new fields
15
+ * may be added, old fields are tolerated on read.
16
+ */
17
+ /**
18
+ * Compare two semver strings (stripping a leading `v` and any pre-release
19
+ * tag after a hyphen — we don't publish prereleases). Returns:
20
+ * 1 if a > b
21
+ * -1 if a < b
22
+ * 0 if equal or unparseable
23
+ */
24
+ export declare function compareSemver(a: string, b: string): number;
25
+ /**
26
+ * Refresh the cache in the background if it's stale. Never throws, never
27
+ * awaited by callers — result lands before next startup.
28
+ */
29
+ export declare function kickoffVersionCheck(): void;
30
+ export interface UpdateInfo {
31
+ current: string;
32
+ latest: string;
33
+ }
34
+ /**
35
+ * Sync check against the cached latest. Returns update info if the cache
36
+ * knows of a newer version, null otherwise. Safe to call before the first
37
+ * background check settles — returns null (we don't speculate).
38
+ */
39
+ export declare function getAvailableUpdate(): UpdateInfo | null;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Update-check utility.
3
+ *
4
+ * Quietly pings npm once per day, caches the latest published version in
5
+ * `~/.blockrun/version-check.json`, and exposes a sync helper the CLI
6
+ * uses to nudge users when they're behind. The check is non-blocking:
7
+ * fire-and-forget at startup, render the notice on the *next* run if the
8
+ * network was slow the first time. Users never wait on it.
9
+ *
10
+ * Respects two opt-outs:
11
+ * - `FRANKLIN_NO_UPDATE_CHECK=1` — explicit user preference
12
+ * - CI-like environments (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, etc.)
13
+ *
14
+ * Cache format is intentionally small and forward-compatible: new fields
15
+ * may be added, old fields are tolerated on read.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { BLOCKRUN_DIR, VERSION, USER_AGENT } from './config.js';
20
+ const CACHE_FILE = path.join(BLOCKRUN_DIR, 'version-check.json');
21
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // once per day
22
+ const FETCH_TIMEOUT_MS = 2_000;
23
+ const REGISTRY_URL = 'https://registry.npmjs.org/@blockrun/franklin/latest';
24
+ function isDisabled() {
25
+ if (process.env.FRANKLIN_NO_UPDATE_CHECK === '1')
26
+ return true;
27
+ // Common CI signals — no point nagging headless runners.
28
+ return Boolean(process.env.CI ||
29
+ process.env.GITHUB_ACTIONS ||
30
+ process.env.GITLAB_CI ||
31
+ process.env.BUILDKITE ||
32
+ process.env.CIRCLECI);
33
+ }
34
+ function readCache() {
35
+ try {
36
+ const raw = fs.readFileSync(CACHE_FILE, 'utf-8');
37
+ const parsed = JSON.parse(raw);
38
+ if (typeof parsed.latestVersion === 'string' && typeof parsed.checkedAt === 'number') {
39
+ return { latestVersion: parsed.latestVersion, checkedAt: parsed.checkedAt };
40
+ }
41
+ }
42
+ catch { /* no cache, bad JSON, first run — all handled by returning null */ }
43
+ return null;
44
+ }
45
+ function writeCache(data) {
46
+ try {
47
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
48
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
49
+ }
50
+ catch { /* cache write is best-effort — never crash startup over it */ }
51
+ }
52
+ /**
53
+ * Compare two semver strings (stripping a leading `v` and any pre-release
54
+ * tag after a hyphen — we don't publish prereleases). Returns:
55
+ * 1 if a > b
56
+ * -1 if a < b
57
+ * 0 if equal or unparseable
58
+ */
59
+ export function compareSemver(a, b) {
60
+ const parse = (v) => {
61
+ const core = v.replace(/^v/, '').split('-')[0];
62
+ const parts = core.split('.').map(n => Number.parseInt(n, 10));
63
+ if (parts.length !== 3 || parts.some(Number.isNaN))
64
+ return null;
65
+ return [parts[0], parts[1], parts[2]];
66
+ };
67
+ const pa = parse(a);
68
+ const pb = parse(b);
69
+ if (!pa || !pb)
70
+ return 0;
71
+ for (let i = 0; i < 3; i++) {
72
+ if (pa[i] > pb[i])
73
+ return 1;
74
+ if (pa[i] < pb[i])
75
+ return -1;
76
+ }
77
+ return 0;
78
+ }
79
+ async function fetchLatestVersion() {
80
+ const ctrl = new AbortController();
81
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
82
+ try {
83
+ const res = await fetch(REGISTRY_URL, {
84
+ signal: ctrl.signal,
85
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
86
+ });
87
+ if (!res.ok)
88
+ return null;
89
+ const body = (await res.json());
90
+ return typeof body.version === 'string' ? body.version : null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+ /**
100
+ * Refresh the cache in the background if it's stale. Never throws, never
101
+ * awaited by callers — result lands before next startup.
102
+ */
103
+ export function kickoffVersionCheck() {
104
+ if (isDisabled())
105
+ return;
106
+ const cache = readCache();
107
+ if (cache && Date.now() - cache.checkedAt < CHECK_INTERVAL_MS)
108
+ return;
109
+ // Detach from the event loop so startup doesn't wait on network.
110
+ // Node keeps the process alive until the fetch settles, which is fine
111
+ // for short-lived CLI invocations and irrelevant for long-running
112
+ // interactive sessions.
113
+ void fetchLatestVersion().then(latest => {
114
+ if (!latest)
115
+ return;
116
+ writeCache({ latestVersion: latest, checkedAt: Date.now() });
117
+ });
118
+ }
119
+ /**
120
+ * Sync check against the cached latest. Returns update info if the cache
121
+ * knows of a newer version, null otherwise. Safe to call before the first
122
+ * background check settles — returns null (we don't speculate).
123
+ */
124
+ export function getAvailableUpdate() {
125
+ if (isDisabled())
126
+ return null;
127
+ const cache = readCache();
128
+ if (!cache)
129
+ return null;
130
+ if (compareSemver(cache.latestVersion, VERSION) > 0) {
131
+ return { current: VERSION, latest: cache.latestVersion };
132
+ }
133
+ return null;
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.9",
3
+ "version": "3.8.11",
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": {