@blockrun/franklin 3.8.8 → 3.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/agent/error-classifier.js +1 -0
  2. package/dist/agent/llm.d.ts +7 -0
  3. package/dist/agent/llm.js +48 -7
  4. package/dist/agent/loop.js +66 -3
  5. package/dist/agent/permissions.js +2 -2
  6. package/dist/agent/types.d.ts +7 -0
  7. package/dist/banner.js +15 -0
  8. package/dist/commands/start.d.ts +4 -0
  9. package/dist/commands/start.js +72 -2
  10. package/dist/index.js +11 -3
  11. package/dist/panel/html.js +111 -21
  12. package/dist/panel/server.js +15 -4
  13. package/dist/tools/activate.d.ts +29 -0
  14. package/dist/tools/activate.js +96 -0
  15. package/dist/tools/index.js +2 -0
  16. package/dist/tools/tool-categories.d.ts +22 -0
  17. package/dist/tools/tool-categories.js +44 -0
  18. package/dist/tools/trading-execute.d.ts +11 -21
  19. package/dist/tools/trading-execute.js +43 -130
  20. package/dist/tools/trading-views.d.ts +64 -0
  21. package/dist/tools/trading-views.js +115 -0
  22. package/dist/tools/trading.js +86 -7
  23. package/dist/tools/webhook.d.ts +18 -0
  24. package/dist/tools/webhook.js +185 -0
  25. package/dist/trading/data.d.ts +24 -1
  26. package/dist/trading/data.js +67 -102
  27. package/dist/trading/providers/blockrun/client.d.ts +48 -0
  28. package/dist/trading/providers/blockrun/client.js +253 -0
  29. package/dist/trading/providers/blockrun/price.d.ts +24 -0
  30. package/dist/trading/providers/blockrun/price.js +110 -0
  31. package/dist/trading/providers/coingecko/client.d.ts +20 -0
  32. package/dist/trading/providers/coingecko/client.js +87 -0
  33. package/dist/trading/providers/coingecko/markets.d.ts +3 -0
  34. package/dist/trading/providers/coingecko/markets.js +25 -0
  35. package/dist/trading/providers/coingecko/ohlcv.d.ts +3 -0
  36. package/dist/trading/providers/coingecko/ohlcv.js +29 -0
  37. package/dist/trading/providers/coingecko/price.d.ts +11 -0
  38. package/dist/trading/providers/coingecko/price.js +41 -0
  39. package/dist/trading/providers/coingecko/trending.d.ts +3 -0
  40. package/dist/trading/providers/coingecko/trending.js +22 -0
  41. package/dist/trading/providers/fetcher.d.ts +43 -0
  42. package/dist/trading/providers/fetcher.js +45 -0
  43. package/dist/trading/providers/registry.d.ts +45 -0
  44. package/dist/trading/providers/registry.js +82 -0
  45. package/dist/trading/providers/standard-models.d.ts +94 -0
  46. package/dist/trading/providers/standard-models.js +21 -0
  47. package/dist/trading/providers/telemetry.d.ts +51 -0
  48. package/dist/trading/providers/telemetry.js +115 -0
  49. package/dist/ui/app.js +28 -2
  50. package/package.json +1 -1
@@ -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 },
@@ -13,6 +13,8 @@ import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS, getMaxOutputT
13
13
  import { classifyAgentError } from './error-classifier.js';
14
14
  import { SessionToolGuard } from './tool-guard.js';
15
15
  import { resetToolSessionState } from '../tools/index.js';
16
+ import { CORE_TOOL_NAMES, dynamicToolsEnabled } from '../tools/tool-categories.js';
17
+ import { createActivateToolCapability } from '../tools/activate.js';
16
18
  import { recordUsage } from '../stats/tracker.js';
17
19
  import { recordSessionUsage } from '../stats/session-tracker.js';
18
20
  import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
@@ -317,11 +319,36 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
317
319
  chain: config.chain,
318
320
  debug: config.debug,
319
321
  });
322
+ // ── Dynamic tool visibility ──
323
+ // Register ActivateTool before building the capability map so the agent
324
+ // can always reach the meta-tool. When FRANKLIN_DYNAMIC_TOOLS=0 is set,
325
+ // `activeTools` is seeded with every registered name — behaves as the
326
+ // pre-3.8.9 static registry.
320
327
  const capabilityMap = new Map();
321
328
  for (const cap of config.capabilities) {
322
329
  capabilityMap.set(cap.spec.name, cap);
323
330
  }
324
- const toolDefs = config.capabilities.map((c) => c.spec);
331
+ const activeTools = new Set();
332
+ const dynamicTools = dynamicToolsEnabled();
333
+ if (dynamicTools) {
334
+ for (const name of CORE_TOOL_NAMES) {
335
+ if (capabilityMap.has(name))
336
+ activeTools.add(name);
337
+ }
338
+ }
339
+ else {
340
+ for (const cap of config.capabilities)
341
+ activeTools.add(cap.spec.name);
342
+ }
343
+ const activateToolCap = createActivateToolCapability({ activeTools, allTools: capabilityMap });
344
+ capabilityMap.set(activateToolCap.spec.name, activateToolCap);
345
+ if (dynamicTools)
346
+ activeTools.add(activateToolCap.spec.name);
347
+ const allToolDefs = [...capabilityMap.values()].map(c => c.spec);
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;
325
352
  const maxTurns = config.maxTurns ?? 15;
326
353
  const workDir = config.workingDir ?? process.cwd();
327
354
  const permissions = new PermissionManager(config.permissionMode ?? 'default', config.permissionPromptFn);
@@ -576,6 +603,20 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
576
603
  '4. Think step by step — show your reasoning explicitly when it adds value\n' +
577
604
  'Prioritize correctness and thoroughness over speed.');
578
605
  }
606
+ // ── Dynamic tool visibility hint ──
607
+ // When the core/on-demand split is active, tell every model up front
608
+ // that its tool list is intentionally small and that extras can be
609
+ // pulled via ActivateTool. Kept byte-stable across turns (no tool
610
+ // names inlined) so the prompt cache still holds.
611
+ if (dynamicTools && allToolDefs.length > activeTools.size) {
612
+ systemParts.push('# Tool Inventory\n' +
613
+ 'Your current tool list is intentionally minimal. Additional tools ' +
614
+ '(web search, image/video/music generation, trading, content, brain ' +
615
+ 'recall, etc.) are available on demand. Call `ActivateTool()` with ' +
616
+ 'no arguments to see what is available, then call `ActivateTool({ ' +
617
+ '"names": ["<name>"] })` to enable the ones you need. Activated ' +
618
+ 'tools become visible on the next turn.');
619
+ }
579
620
  // ── Context awareness injection ──
580
621
  // Tell the model how full its context window is so it can self-regulate.
581
622
  // At high usage, nudge it to be concise and avoid unnecessary tool calls.
@@ -611,8 +652,9 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
611
652
  let usage;
612
653
  let stopReason;
613
654
  // Create streaming executor for concurrent tool execution
655
+ const activeCapabilityMap = buildActiveCapabilityMap();
614
656
  const streamExec = new StreamingExecutor({
615
- handlers: capabilityMap,
657
+ handlers: activeCapabilityMap,
616
658
  scope: {
617
659
  workingDir: workDir,
618
660
  abortSignal: abort.signal,
@@ -669,7 +711,10 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
669
711
  }
670
712
  // Build per-call tool defs, max_tokens, and system prompt
671
713
  // (planning calls get no tools + short output + planning prompt)
672
- let callToolDefs = toolDefs;
714
+ // Dynamic visibility: `buildCallToolDefs()` returns only the active set
715
+ // (core + any the agent pulled via ActivateTool). Re-evaluated every
716
+ // turn so newly activated tools take effect immediately.
717
+ let callToolDefs = buildCallToolDefs();
673
718
  let callMaxTokens = maxTokens;
674
719
  let callSystemPrompt = systemPrompt;
675
720
  if (planActive && loopCount === 1) {
@@ -918,6 +963,24 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
918
963
  const opusCost = (inputTokens / 1_000_000) * OPUS_PRICING.input
919
964
  + (usage.outputTokens / 1_000_000) * OPUS_PRICING.output;
920
965
  sessionSavedVsOpus += Math.max(0, opusCost - costEstimate);
966
+ // ── Max-spend guard ──
967
+ // Session-level cost ceiling. Cron/daily drivers pass this to bound a
968
+ // single run ("spend at most $0.50 for today's digest"); interactive
969
+ // users can pass it to feel safe walking away. Hits as soon as accumulated
970
+ // cost crosses the cap — the last call that tipped us over still runs,
971
+ // but no further API calls are made.
972
+ const maxSpend = config.maxSpendUsd;
973
+ if (typeof maxSpend === 'number' && Number.isFinite(maxSpend) && maxSpend > 0 &&
974
+ sessionCostUsd >= maxSpend) {
975
+ onEvent({
976
+ kind: 'text_delta',
977
+ text: `\n\n_Max-spend reached: $${sessionCostUsd.toFixed(4)} ≥ cap $${maxSpend.toFixed(2)}. ` +
978
+ `Stopping session — further calls would exceed the budget._\n`,
979
+ });
980
+ persistSessionMeta();
981
+ onEvent({ kind: 'turn_done', reason: 'budget' });
982
+ return history;
983
+ }
921
984
  // ── Max output tokens recovery ──
922
985
  if (stopReason === 'max_tokens' && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
923
986
  recoveryAttempts++;
@@ -33,10 +33,10 @@ function isCommonDevCommand(cmd) {
33
33
  return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
34
34
  }
35
35
  // ─── Default Rules ─────────────────────────────────────────────────────────
36
- const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
36
+ const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
37
37
  const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
38
38
  const DEFAULT_RULES = {
39
- allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'],
39
+ allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'],
40
40
  deny: [],
41
41
  ask: ['Write', 'Edit', 'Bash', 'Agent', 'PostToX'],
42
42
  };
@@ -155,4 +155,11 @@ export interface AgentConfig {
155
155
  * unset. Format: "<driver>:<owner-or-chat-id>", e.g. "telegram:12345".
156
156
  */
157
157
  sessionChannel?: string;
158
+ /**
159
+ * Hard cap on total USD spend for this session. When accumulated API cost
160
+ * crosses the cap, the loop stops with `reason: 'budget'`. Zero/negative
161
+ * values disable the cap. Primary use case: cron/daily drivers that must
162
+ * bound a single run to keep autonomous execution inside a known envelope.
163
+ */
164
+ maxSpendUsd?: number;
158
165
  }
package/dist/banner.js CHANGED
@@ -82,6 +82,21 @@ function padVisible(s, targetWidth) {
82
82
  return s + '\x1b[0m' + ' '.repeat(targetWidth - current);
83
83
  }
84
84
  export function printBanner(version) {
85
+ const style = process.env.FRANKLIN_BANNER?.toLowerCase();
86
+ if (style === 'full' || style === 'legacy') {
87
+ printLegacyBanner(version);
88
+ return;
89
+ }
90
+ printCompactBanner(version);
91
+ }
92
+ function printCompactBanner(version) {
93
+ const title = chalk.bold.hex(GOLD_START)('FRANKLIN');
94
+ const meta = chalk.dim(` · blockrun.ai · v${version}`);
95
+ console.log(`${title}${meta}`);
96
+ console.log(chalk.dim('The AI agent with a wallet'));
97
+ console.log('');
98
+ }
99
+ function printLegacyBanner(version) {
85
100
  const termWidth = process.stdout.columns ?? 80;
86
101
  const useSideBySide = termWidth >= MIN_WIDTH_FOR_PORTRAIT;
87
102
  if (useSideBySide) {
@@ -7,6 +7,10 @@ interface StartOptions {
7
7
  resume?: string | boolean | 'picker';
8
8
  /** Continue: resume most recent session matching the current working directory */
9
9
  continue?: boolean;
10
+ /** Hard USD cap on total session spend. Stops the loop when exceeded. */
11
+ maxSpend?: string | number;
12
+ /** Run a single prompt non-interactively, then exit. For cron/scripted use. */
13
+ prompt?: string;
10
14
  }
11
15
  export declare function startCommand(options: StartOptions): Promise<void>;
12
16
  export {};
@@ -74,6 +74,49 @@ export async function startCommand(options) {
74
74
  // Default: free NVIDIA model — zero wallet charges until user explicitly switches
75
75
  model = 'nvidia/nemotron-ultra-253b';
76
76
  }
77
+ const workDir = process.cwd();
78
+ // --prompt batch mode: skip all interactive startup UI/side effects so
79
+ // stdout stays clean for cron/scripts. Keep the capability surface to the
80
+ // built-ins only — no panel, no MCP autoconnect, no wallet/banner chatter.
81
+ if (options.prompt) {
82
+ if (options.resume === true || options.resume === 'picker') {
83
+ console.error(chalk.red('`--prompt` requires `--resume` to include an explicit session id.'));
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ const systemInstructions = assembleInstructions(workDir, model);
88
+ const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities, model);
89
+ const { registerMoAConfig } = await import('../tools/moa.js');
90
+ registerMoAConfig(apiUrl, chain, model);
91
+ const capabilities = [...allCapabilities, subAgent];
92
+ if (options.debug) {
93
+ const issues = validateToolDescriptions(capabilities);
94
+ for (const issue of issues) {
95
+ console.error(`[validate] ${issue.severity}: ${issue.toolName} — ${issue.issue}`);
96
+ }
97
+ }
98
+ const agentConfig = {
99
+ model,
100
+ apiUrl,
101
+ chain,
102
+ systemInstructions,
103
+ capabilities,
104
+ maxTurns: 100,
105
+ workingDir: workDir,
106
+ permissionMode: 'trust',
107
+ debug: options.debug,
108
+ resumeSessionId: (typeof options.resume === 'string' && options.resume !== 'picker')
109
+ ? options.resume
110
+ : continueResolvedId,
111
+ ...(options.maxSpend != null
112
+ ? { maxSpendUsd: Number(options.maxSpend) }
113
+ : {}),
114
+ };
115
+ const exitCode = await runOneShot(agentConfig, options.prompt);
116
+ flushStats();
117
+ process.exitCode = exitCode;
118
+ return;
119
+ }
77
120
  // Warn when a paid model is active so users know they'll be charged
78
121
  const FREE_MODELS = new Set([
79
122
  'nvidia/nemotron-ultra-253b',
@@ -114,7 +157,6 @@ export async function startCommand(options) {
114
157
  catch { /* migration is optional */ }
115
158
  }
116
159
  printBanner(version);
117
- const workDir = process.cwd();
118
160
  // Auto-start panel in background unless explicitly disabled.
119
161
  // Binds loopback-only (wallet secrets on /api/wallet/secret — never expose on LAN).
120
162
  let panelUrl;
@@ -240,9 +282,13 @@ export async function startCommand(options) {
240
282
  workingDir: workDir,
241
283
  // Non-TTY (piped) input = scripted mode → trust all tools automatically.
242
284
  // Interactive TTY = default mode (prompts for Bash/Write/Edit).
243
- permissionMode: (options.trust || !process.stdin.isTTY) ? 'trust' : 'default',
285
+ // --prompt is also scripted; the cron driver never sees a TTY.
286
+ permissionMode: (options.trust || options.prompt || !process.stdin.isTTY) ? 'trust' : 'default',
244
287
  debug: options.debug,
245
288
  resumeSessionId,
289
+ ...(options.maxSpend != null
290
+ ? { maxSpendUsd: Number(options.maxSpend) }
291
+ : {}),
246
292
  };
247
293
  // Bootstrap learnings from existing CLAUDE.md on first run (async, non-blocking)
248
294
  Promise.all([
@@ -262,6 +308,30 @@ export async function startCommand(options) {
262
308
  await runWithBasicUI(agentConfig, model, workDir);
263
309
  }
264
310
  }
311
+ // ─── One-shot mode (franklin --prompt "...") ──────────────────────────────
312
+ // Used by cron drivers. Non-interactive, prints text deltas to stdout as
313
+ // they stream, honors --max-spend, exits 0 on completion / 1 on error.
314
+ async function runOneShot(agentConfig, prompt) {
315
+ let delivered = false;
316
+ let exitCode = 0;
317
+ const getInput = async () => {
318
+ if (delivered)
319
+ return null;
320
+ delivered = true;
321
+ return prompt;
322
+ };
323
+ await interactiveSession(agentConfig, getInput, (event) => {
324
+ if (event.kind === 'text_delta') {
325
+ process.stdout.write(event.text);
326
+ }
327
+ else if (event.kind === 'turn_done') {
328
+ if (event.reason === 'error')
329
+ exitCode = 1;
330
+ process.stdout.write('\n');
331
+ }
332
+ });
333
+ return exitCode;
334
+ }
265
335
  // ─── Ink UI (interactive terminal) ─────────────────────────────────────────
266
336
  async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, onBalanceReady, fetchBalance) {
267
337
  const startSnapshot = snapshotStats();
package/dist/index.js CHANGED
@@ -43,6 +43,8 @@ program
43
43
  .option('--trust', 'Trust mode — skip permission prompts for all tools')
44
44
  .option('-r, --resume [sessionId]', 'Resume a session by ID (or show picker if omitted)')
45
45
  .option('-c, --continue', 'Continue the most recent session in this directory')
46
+ .option('--max-spend <usd>', 'Hard USD cap on total session API spend — session stops when exceeded')
47
+ .option('-p, --prompt <text>', 'Run a single prompt non-interactively (for cron/scripted use)')
46
48
  .action((options) => startCommand({ ...options, version }));
47
49
  program
48
50
  .command('resume [sessionId]')
@@ -209,7 +211,7 @@ const args = process.argv.slice(2);
209
211
  const firstArg = args[0];
210
212
  const HELP_FLAGS = new Set(['-h', '--help']);
211
213
  const VERSION_FLAGS = new Set(['-V', '--version']);
212
- const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model', '-r', '--resume', '-c', '--continue']);
214
+ const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model', '-r', '--resume', '-c', '--continue', '-p', '--prompt', '--max-spend']);
213
215
  function hasAnyFlag(argv, flags) {
214
216
  return argv.some(arg => flags.has(arg));
215
217
  }
@@ -227,6 +229,12 @@ function parseStartFlags(argv, startIdx = 0) {
227
229
  else if ((arg === '-m' || arg === '--model') && argv[i + 1]) {
228
230
  opts.model = argv[++i];
229
231
  }
232
+ else if ((arg === '-p' || arg === '--prompt') && argv[i + 1]) {
233
+ opts.prompt = argv[++i];
234
+ }
235
+ else if (arg === '--max-spend' && argv[i + 1]) {
236
+ opts.maxSpend = argv[++i];
237
+ }
230
238
  else if (arg === '-c' || arg === '--continue') {
231
239
  opts.continue = true;
232
240
  }
@@ -257,7 +265,7 @@ if (firstArg === 'solana' || firstArg === 'base') {
257
265
  saveChain(firstArg);
258
266
  const startOpts = parseStartFlags(args, 1);
259
267
  await startCommand(startOpts);
260
- process.exit(0);
268
+ process.exit(process.exitCode ?? 0);
261
269
  }
262
270
  else if (!firstArg || firstArg.startsWith('-')) {
263
271
  if (hasAnyFlag(args, HELP_FLAGS) && hasStartOnlyFlag(args)) {
@@ -273,7 +281,7 @@ else if (!firstArg || firstArg.startsWith('-')) {
273
281
  // No subcommand or only flags — treat as 'start' with flags
274
282
  const startOpts = parseStartFlags(args, 0);
275
283
  await startCommand(startOpts);
276
- process.exit(0);
284
+ process.exit(process.exitCode ?? 0);
277
285
  }
278
286
  else {
279
287
  program.parse();
@@ -371,14 +371,14 @@ a:hover { text-decoration:underline; }
371
371
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4z"/></svg>
372
372
  Wallet
373
373
  </button>
374
+ <button class="nav-item" data-tab="markets">
375
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M7 14l4-4 4 4 5-5"/></svg>
376
+ Markets
377
+ </button>
374
378
  <button class="nav-item" data-tab="sessions">
375
379
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
376
380
  Sessions
377
381
  </button>
378
- <button class="nav-item" data-tab="social">
379
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4l11.733 16h4.267l-11.733-16z"/><path d="M4 20l6.768-6.768M15.232 11.232L20 4"/></svg>
380
- Social
381
- </button>
382
382
  <button class="nav-item" data-tab="learnings">
383
383
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
384
384
  Learnings
@@ -542,16 +542,34 @@ a:hover { text-decoration:underline; }
542
542
  <div class="session-detail" id="session-detail" style="display:none"></div>
543
543
  </div>
544
544
 
545
- <!-- Social -->
546
- <div class="tab" id="tab-social">
545
+ <!-- Markets -->
546
+ <div class="tab" id="tab-markets">
547
547
  <div class="content-header">
548
- <h2>Social</h2>
549
- <p>X/Twitter engagement stats</p>
548
+ <h2>Markets</h2>
549
+ <p>How Franklin gets trading data — and what it costs.</p>
550
550
  </div>
551
- <div class="grid grid-4" id="social-stats"></div>
552
- <div class="card" style="margin-top:12px">
553
- <h3>Recent Activity</h3>
554
- <div id="social-feed" class="empty">No social activity yet</div>
551
+
552
+ <div class="grid grid-4">
553
+ <div class="card"><h3>Calls today</h3><div class="metric" id="mk-calls">&mdash;</div></div>
554
+ <div class="card"><h3>Spend today</h3><div class="metric gold" id="mk-spend">&mdash;</div></div>
555
+ <div class="card"><h3>p50 latency</h3><div class="metric" id="mk-p50">&mdash;</div></div>
556
+ <div class="card"><h3>Payment chain</h3><div class="metric" id="mk-chain">&mdash;</div></div>
557
+ </div>
558
+
559
+ <div style="display:grid;grid-template-columns:1.1fr 1fr;gap:14px;margin-top:14px">
560
+ <div class="card">
561
+ <h3>Data pipeline</h3>
562
+ <p style="color:var(--text-dim);font-size:12px;margin:4px 0 14px">
563
+ Each asset class routes through the provider registry to the active upstream.
564
+ </p>
565
+ <div id="mk-pipeline" style="font-family:var(--mono);font-size:12px;line-height:1.75"></div>
566
+ </div>
567
+ <div class="card">
568
+ <h3>Providers</h3>
569
+ <div id="mk-providers" style="margin-top:6px"></div>
570
+ <h3 style="margin-top:18px">Recent paid calls</h3>
571
+ <div id="mk-paid" class="empty" style="margin-top:6px">No paid calls yet</div>
572
+ </div>
555
573
  </div>
556
574
  </div>
557
575
 
@@ -588,6 +606,7 @@ a:hover { text-decoration:underline; }
588
606
  </div>
589
607
  <div id="audit-list" style="font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;"></div>
590
608
  </div>
609
+
591
610
  </div>
592
611
 
593
612
  <script>
@@ -716,14 +735,84 @@ document.getElementById('session-search').addEventListener('input', (e) => {
716
735
  }, 300);
717
736
  });
718
737
 
719
- async function loadSocial() {
720
- const social = await api('social');
721
- if (!social) return;
722
- document.getElementById('social-stats').innerHTML =
723
- '<div class="card"><h3>Posted</h3><div class="metric success">' + (social.posted || 0) + '</div></div>' +
724
- '<div class="card"><h3>Drafted</h3><div class="metric">' + (social.drafted || 0) + '</div></div>' +
725
- '<div class="card"><h3>Skipped</h3><div class="metric">' + (social.skipped || 0) + '</div></div>' +
726
- '<div class="card"><h3>Social Cost</h3><div class="metric gold">' + usd(social.totalCost || 0) + '</div></div>';
738
+ async function loadMarkets() {
739
+ const data = await api('markets');
740
+ if (!data) return;
741
+
742
+ const calls = (data.totals && data.totals.callsToday) || 0;
743
+ const spend = (data.totals && data.totals.spendUsdToday) || 0;
744
+ const p50 = data.totals && data.totals.p50LatencyMs;
745
+ document.getElementById('mk-calls').textContent = String(calls);
746
+ document.getElementById('mk-spend').textContent = usd(spend);
747
+ document.getElementById('mk-p50').textContent = (p50 == null) ? '—' : (p50 + ' ms');
748
+ document.getElementById('mk-chain').textContent = (data.chain || 'base').toUpperCase();
749
+
750
+ // Pipeline: Franklin → registry → per-asset-class provider → endpoint
751
+ const rows = (data.wiring || []).filter(function(r){ return r.kind === 'price'; });
752
+ const singletonRows = (data.wiring || []).filter(function(r){ return r.kind !== 'price'; });
753
+ const providerLabel = function(name) {
754
+ if (name === 'coingecko') return '<span style="color:var(--success)">CoinGecko</span>';
755
+ if (name === 'blockrun') return '<span style="color:var(--gold)">BlockRun Gateway</span>';
756
+ return esc(name);
757
+ };
758
+ const pipeLines = [
759
+ '<div>Franklin agent</div>',
760
+ '<div style="color:var(--text-dim);padding-left:8px">↓</div>',
761
+ '<div>Provider registry</div>',
762
+ '<div style="color:var(--text-dim);padding-left:8px">↓</div>',
763
+ ];
764
+ rows.forEach(function(r, i){
765
+ const last = i === rows.length - 1;
766
+ const branch = last ? '└' : '├';
767
+ const paid = r.paid ? ' <span style="color:var(--gold);font-size:10px">◆ x402</span>' : '';
768
+ pipeLines.push(
769
+ '<div>&nbsp;' + branch + '─ ' + esc(r.assetClass).padEnd(9, ' ') +
770
+ ' → ' + providerLabel(r.provider) + paid + '</div>'
771
+ );
772
+ });
773
+ pipeLines.push('<div style="margin-top:10px;color:var(--text-dim);font-size:11px">Other singleton kinds:</div>');
774
+ singletonRows.forEach(function(r){
775
+ pipeLines.push(
776
+ '<div style="color:var(--text-dim);font-size:11px">&nbsp;&nbsp;' +
777
+ esc(r.kind) + ' → ' + providerLabel(r.provider) + '</div>'
778
+ );
779
+ });
780
+ document.getElementById('mk-pipeline').innerHTML = pipeLines.join('');
781
+
782
+ // Providers health
783
+ const statusChip = function(s){
784
+ if (s === 'ok') return '<span class="dot on"></span> <span style="color:var(--success)">OK</span>';
785
+ if (s === 'degraded') return '<span class="dot off"></span> <span style="color:var(--danger)">degraded</span>';
786
+ return '<span class="dot" style="background:var(--text-dim)"></span> <span style="color:var(--text-dim)">cold</span>';
787
+ };
788
+ const providers = data.providers || [];
789
+ document.getElementById('mk-providers').innerHTML = providers.length === 0 ? '<div class="empty">No calls recorded yet.</div>' : providers.map(function(p){
790
+ const since = p.lastOkAt ? Math.round((Date.now() - p.lastOkAt) / 1000) + 's ago' : '—';
791
+ return '<div style="display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid var(--border);font-size:12px">' +
792
+ '<span>' + statusChip(p.status) + ' &nbsp;<strong>' + esc(p.name) + '</strong></span>' +
793
+ '<span style="color:var(--text-dim);font-family:var(--mono);font-size:11px">' +
794
+ p.calls + ' calls · p50 ' + (p.p50LatencyMs == null ? '—' : p.p50LatencyMs + 'ms') + ' · last ' + since +
795
+ '</span>' +
796
+ '</div>';
797
+ }).join('');
798
+
799
+ // Recent paid calls
800
+ const paid = data.recentPaidCalls || [];
801
+ const paidBox = document.getElementById('mk-paid');
802
+ if (paid.length === 0) {
803
+ paidBox.className = 'empty';
804
+ paidBox.textContent = 'No paid calls yet — stocks ship in the next release.';
805
+ } else {
806
+ paidBox.className = '';
807
+ paidBox.innerHTML = paid.map(function(r){
808
+ const age = Math.round((Date.now() - r.ts) / 1000) + 's ago';
809
+ return '<div style="display:flex;justify-content:space-between;padding:4px 0;font-family:var(--mono);font-size:12px">' +
810
+ '<span>' + esc(r.endpoint) + '</span>' +
811
+ '<span class="gold">' + usd(r.costUsd) + '</span>' +
812
+ '<span style="color:var(--text-dim)">' + age + '</span>' +
813
+ '</div>';
814
+ }).join('');
815
+ }
727
816
  }
728
817
 
729
818
  async function loadLearnings() {
@@ -909,9 +998,10 @@ document.querySelector('[data-tab="audit"]')?.addEventListener('click', loadAudi
909
998
 
910
999
  loadOverview();
911
1000
  loadSessions();
912
- loadSocial();
1001
+ loadMarkets();
913
1002
  loadLearnings();
914
1003
  loadWallet();
1004
+ document.querySelector('[data-tab="markets"]')?.addEventListener('click', loadMarkets);
915
1005
  setInterval(() => api('wallet').then(w => {
916
1006
  if (w) {
917
1007
  document.getElementById('balance').textContent = usdBig(w.balance) + ' USDC';
@@ -13,7 +13,8 @@ import { listSessions, loadSessionHistory } from '../session/storage.js';
13
13
  import { searchSessions } from '../session/search.js';
14
14
  import { loadLearnings } from '../learnings/store.js';
15
15
  import { readAudit } from '../stats/audit.js';
16
- import { getStats as getSocialStats } from '../social/db.js';
16
+ import { snapshot as marketsSnapshot } from '../trading/providers/telemetry.js';
17
+ import { describeWiring } from '../trading/providers/registry.js';
17
18
  import { getHTML } from './html.js';
18
19
  const sseClients = new Set();
19
20
  function json(res, data, status = 200) {
@@ -312,9 +313,19 @@ export function createPanelServer(port) {
312
313
  }
313
314
  return;
314
315
  }
315
- if (p === '/api/social') {
316
- const stats = getSocialStats();
317
- json(res, stats);
316
+ if (p === '/api/markets') {
317
+ // Snapshot of every active data provider for the Markets panel:
318
+ // pipeline wiring (which endpoint serves which asset class), live
319
+ // health + latency per provider, and today's paid-call ledger.
320
+ const snap = marketsSnapshot();
321
+ const wiring = describeWiring();
322
+ json(res, {
323
+ chain: loadChain(),
324
+ wiring,
325
+ providers: snap.providers,
326
+ totals: snap.totals,
327
+ recentPaidCalls: snap.recentPaidCalls,
328
+ });
318
329
  return;
319
330
  }
320
331
  if (p === '/api/learnings') {