@blockrun/franklin 3.15.88 → 3.15.89

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.
@@ -5,6 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { execSync } from 'node:child_process';
8
+ import { BLOCKRUN_DIR } from '../config.js';
8
9
  import { getWalletAddress as getBaseWalletAddress } from '@blockrun/llm';
9
10
  import { Keypair } from '@solana/web3.js';
10
11
  import bs58 from 'bs58';
@@ -92,7 +93,7 @@ Go straight to the point. Lead with the action, not the reasoning. Do not restat
92
93
 
93
94
  The exception: a single short sentence between tool calls is fine when it tells the user something they would otherwise miss — a finding ("Build passes — moving on to tests."), a course correction ("That approach won't work — switching to X."), or a one-line status before a long-running operation. One sentence per update is enough.
94
95
 
95
- **No internal-language leakage.** Always write your visible response in the same language the user is using. If your private reasoning happens in a different language (English while the user writes Chinese, Korean while the user writes Chinese, etc.), do NOT let phrases from that language appear in the user-facing text. The user should never see a stray "좋아", "OK now", or "Alright" in the middle of a Chinese reply.
96
+ **No internal-language leakage.** Always write your visible response in the same language the user is using. If your private reasoning happens in a different language than the user's message, do NOT let phrases from that language appear in the user-facing text. The user should never see a stray "d'accord", "OK now", or "Alright" in the middle of a reply written in another language.
96
97
 
97
98
  Focus text output on:
98
99
  - Decisions that need the user's input
@@ -159,6 +160,19 @@ After delivering results, if a better data source exists, add one line at the en
159
160
  Do NOT check access before acting. Do NOT explain what you tried. Just deliver, then tip.`;
160
161
  }
161
162
  function getWalletKnowledgeSection() {
163
+ // Read the panel URL persisted by startPanelBackground (start.ts) so we
164
+ // surface the actual bound port — the panel auto-increments past 3100
165
+ // when the default is taken (e.g. a second franklin running). Falls back
166
+ // to the canonical default when the file is missing (panel disabled or
167
+ // never started this session).
168
+ let panelUrl = 'http://localhost:3100';
169
+ try {
170
+ const persisted = fs.readFileSync(path.join(BLOCKRUN_DIR, 'panel-url'), 'utf8').trim();
171
+ if (persisted.startsWith('http://') || persisted.startsWith('https://')) {
172
+ panelUrl = persisted;
173
+ }
174
+ }
175
+ catch { /* fall through to default */ }
162
176
  return `# Wallet Storage (answer "where is my wallet" directly — no searching)
163
177
  Franklin stores wallet keys in ~/.blockrun/. When the user asks about wallet location, answer from this map — do not grep or scan.
164
178
 
@@ -178,7 +192,32 @@ Franklin stores wallet keys in ~/.blockrun/. When the user asks about wallet loc
178
192
  - Use \`franklin stats\` / \`franklin content list\` instead of parsing files when the user asks "how much did I spend".
179
193
  - Programmatic access: import { getWalletAddress, getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm'
180
194
 
181
- When the user asks about "my wallet" without qualifier, default to Base (it's the primary chain shown at launch). Only mention Solana if the chain file says solana or the user explicitly asks.`;
195
+ When the user asks about "my wallet" without qualifier, default to Base (it's the primary chain shown at launch). Only mention Solana if the chain file says solana or the user explicitly asks.
196
+
197
+ ## Funding the wallet ("how do I deposit / recharge / fund / top up", in any language)
198
+
199
+ When the user asks about depositing or funding USDC — in any language — do not describe the steps in chat. **Open the panel wallet page directly in their browser** using Bash, then confirm in chat what you opened and which chain is active.
200
+
201
+ The exact wallet URL for this session:
202
+
203
+ ${panelUrl}/#wallet
204
+
205
+ Bash command to open it (macOS \`open\`, Linux \`xdg-open\`, Windows \`start\`):
206
+
207
+ open ${panelUrl}/#wallet
208
+
209
+ That page is where the deposit address, QR code, live balance, chain switcher, and back-up controls all live. The user lands on it instead of you reciting steps.
210
+
211
+ After running \`open\`:
212
+ - Tell the user one line: "Opened the wallet page — \`${panelUrl}/#wallet\`. Active chain: <base|solana>."
213
+ - Read the active chain from ~/.blockrun/payment-chain so they know which network to send USDC on.
214
+ - Mention USDC is the only accepted token; ETH/SOL on their own won't settle x402 calls.
215
+
216
+ Hard rules:
217
+ - Do NOT print the private key in chat. The panel reveals it behind a click.
218
+ - Do NOT invent a \`franklin deposit\` CLI flow — there isn't one; the panel IS the funding surface.
219
+ - Do NOT hand-craft a different localhost port; the URL above tracks the actual bound port (3100 might have been taken; the panel could be on 3101+).
220
+ - If \`open\` fails (e.g. no GUI on a remote box), fall back to giving them the URL as plain text and tell them to paste it into a browser.`;
182
221
  }
183
222
  function getBlockRunApiSection() {
184
223
  return `# BlockRun Gateway API (the network you live on)
@@ -116,6 +116,15 @@ export declare class ModelClient {
116
116
  private cachedBaseWallet;
117
117
  private cachedSolanaWallet;
118
118
  private walletCacheTime;
119
+ /**
120
+ * USDC actually charged on the most recent x402 settlement, parsed
121
+ * from `details.amount` (micro-USDC → USD). Reset to 0 at the start
122
+ * of every `streamCompletion`, written by `signBasePayment` /
123
+ * `signSolanaPayment`. Callers read it via `getLastPaidUsd()` after
124
+ * the stream completes so franklin-stats.json records the real wallet
125
+ * charge instead of a token-catalog estimate.
126
+ */
127
+ private lastPaidUsd;
119
128
  private static WALLET_CACHE_TTL;
120
129
  constructor(opts: LLMClientOptions);
121
130
  /**
@@ -132,6 +141,13 @@ export declare class ModelClient {
132
141
  * default model.
133
142
  */
134
143
  private resolveVirtualModel;
144
+ /**
145
+ * USDC actually charged for the most recent stream. 0 if no payment
146
+ * was made (free model / cached / pre-stream error). Callers should
147
+ * read this after the stream finishes — before that it carries the
148
+ * value from a previous call.
149
+ */
150
+ getLastPaidUsd(): number;
135
151
  streamCompletion(request: ModelRequest, signal?: AbortSignal): AsyncGenerator<StreamChunk>;
136
152
  private parseNonStreamingMessage;
137
153
  /**
package/dist/agent/llm.js CHANGED
@@ -5,6 +5,7 @@
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 { appendSettlementRow } from '../stats/cost-log.js';
8
9
  import { routeRequest, parseRoutingProfile } from '../router/index.js';
9
10
  import { ThinkTagStripper } from './think-tag-stripper.js';
10
11
  import { isNemotronProseModel, stripNemotronProse } from './nemotron-prose-stripper.js';
@@ -28,6 +29,19 @@ function parseTimeoutEnv(name) {
28
29
  const parsed = raw ? Number.parseInt(raw, 10) : NaN;
29
30
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
30
31
  }
32
+ /**
33
+ * Convert an x402 `details.amount` field (USDC in micro-units, 6 decimals)
34
+ * to a USD float. Mirrors the SDK's `appendCostLog` math so the agent
35
+ * loop, the proxy, and `cost_log.jsonl` all agree to the cent.
36
+ */
37
+ function paymentAmountToUsd(amount) {
38
+ if (amount === undefined || amount === null)
39
+ return 0;
40
+ const n = typeof amount === 'string' ? parseFloat(amount) : amount;
41
+ if (!Number.isFinite(n))
42
+ return 0;
43
+ return n / 1e6;
44
+ }
31
45
  /**
32
46
  * Replace Unicode box-drawing characters with their ASCII equivalents.
33
47
  *
@@ -284,6 +298,15 @@ export class ModelClient {
284
298
  cachedBaseWallet = null;
285
299
  cachedSolanaWallet = null;
286
300
  walletCacheTime = 0;
301
+ /**
302
+ * USDC actually charged on the most recent x402 settlement, parsed
303
+ * from `details.amount` (micro-USDC → USD). Reset to 0 at the start
304
+ * of every `streamCompletion`, written by `signBasePayment` /
305
+ * `signSolanaPayment`. Callers read it via `getLastPaidUsd()` after
306
+ * the stream completes so franklin-stats.json records the real wallet
307
+ * charge instead of a token-catalog estimate.
308
+ */
309
+ lastPaidUsd = 0;
287
310
  static WALLET_CACHE_TTL = 30 * 60 * 1000; // 30 min TTL
288
311
  constructor(opts) {
289
312
  this.apiUrl = opts.apiUrl;
@@ -329,7 +352,19 @@ export class ModelClient {
329
352
  };
330
353
  return FALLBACKS[model] || 'nvidia/qwen3-coder-480b';
331
354
  }
355
+ /**
356
+ * USDC actually charged for the most recent stream. 0 if no payment
357
+ * was made (free model / cached / pre-stream error). Callers should
358
+ * read this after the stream finishes — before that it carries the
359
+ * value from a previous call.
360
+ */
361
+ getLastPaidUsd() {
362
+ return this.lastPaidUsd;
363
+ }
332
364
  async *streamCompletion(request, signal) {
365
+ // Reset the per-call charge tracker. signBasePayment / signSolanaPayment
366
+ // will set it when the gateway demands a 402 settlement.
367
+ this.lastPaidUsd = 0;
333
368
  // Resolve virtual models before any API call
334
369
  const resolvedModel = this.resolveVirtualModel(request.model);
335
370
  if (resolvedModel !== request.model) {
@@ -463,7 +498,7 @@ export class ModelClient {
463
498
  if (response.status === 402) {
464
499
  if (this.debug)
465
500
  console.error('[franklin] Payment required — signing...');
466
- const paymentHeader = await this.signPayment(response);
501
+ const paymentHeader = await this.signPayment(response, request.model);
467
502
  if (!paymentHeader) {
468
503
  yield { kind: 'error', payload: { message: 'Payment signing failed' } };
469
504
  return;
@@ -525,7 +560,7 @@ export class ModelClient {
525
560
  signal: requestController.signal,
526
561
  }), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
527
562
  if (response.status === 402) {
528
- const paymentHeader = await this.signPayment(response);
563
+ const paymentHeader = await this.signPayment(response, request.model);
529
564
  if (!paymentHeader) {
530
565
  yield { kind: 'error', payload: { message: 'Payment signing failed' } };
531
566
  return;
@@ -918,17 +953,17 @@ export class ModelClient {
918
953
  return { content: collected, usage, stopReason };
919
954
  }
920
955
  // ─── Payment ───────────────────────────────────────────────────────────
921
- async signPayment(response) {
956
+ async signPayment(response, model) {
922
957
  try {
923
958
  if (this.chain === 'solana') {
924
- return await this.signSolanaPayment(response);
959
+ return await this.signSolanaPayment(response, model);
925
960
  }
926
- return await this.signBasePayment(response);
961
+ return await this.signBasePayment(response, model);
927
962
  }
928
963
  catch (err) {
929
964
  const msg = err.message || '';
930
965
  if (msg.includes('insufficient') || msg.includes('balance')) {
931
- console.error(`[franklin] Insufficient USDC balance. Run 'franklin balance' to check.`);
966
+ console.error(`[franklin] Insufficient USDC balance. Open http://localhost:3100/#wallet to deposit (or run 'franklin balance').`);
932
967
  }
933
968
  else if (this.debug) {
934
969
  console.error('[franklin] Payment error:', msg);
@@ -939,7 +974,7 @@ export class ModelClient {
939
974
  return null;
940
975
  }
941
976
  }
942
- async signBasePayment(response) {
977
+ async signBasePayment(response, model) {
943
978
  // Refresh wallet cache after TTL to pick up balance/key changes
944
979
  if (!this.cachedBaseWallet || (Date.now() - this.walletCacheTime > ModelClient.WALLET_CACHE_TTL)) {
945
980
  const w = getOrCreateWallet();
@@ -954,6 +989,18 @@ export class ModelClient {
954
989
  throw new Error('No payment requirements in 402 response');
955
990
  const paymentRequired = parsePaymentRequired(paymentHeader);
956
991
  const details = extractPaymentDetails(paymentRequired);
992
+ this.lastPaidUsd = paymentAmountToUsd(details.amount);
993
+ // Mirror the SDK's appendCostLog write so cost_log.jsonl becomes a
994
+ // true wallet-truth ledger covering both SDK helper traffic AND the
995
+ // agent's main LLM stream (which uses this signer, not the SDK).
996
+ // Match SDK schema (model/wallet/network/client_kind) so every row
997
+ // is independently queryable.
998
+ appendSettlementRow('/v1/messages', this.lastPaidUsd, {
999
+ model,
1000
+ wallet: wallet.address,
1001
+ network: details.network || 'base-mainnet',
1002
+ client_kind: 'AgentClient',
1003
+ });
957
1004
  const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
958
1005
  resourceUrl: details.resource?.url || this.apiUrl,
959
1006
  resourceDescription: details.resource?.description || 'BlockRun AI API call',
@@ -962,7 +1009,7 @@ export class ModelClient {
962
1009
  });
963
1010
  return { 'PAYMENT-SIGNATURE': payload };
964
1011
  }
965
- async signSolanaPayment(response) {
1012
+ async signSolanaPayment(response, model) {
966
1013
  if (!this.cachedSolanaWallet || (Date.now() - this.walletCacheTime > ModelClient.WALLET_CACHE_TTL)) {
967
1014
  const w = await getOrCreateSolanaWallet();
968
1015
  this.walletCacheTime = Date.now();
@@ -975,6 +1022,13 @@ export class ModelClient {
975
1022
  throw new Error('No payment requirements in 402 response');
976
1023
  const paymentRequired = parsePaymentRequired(paymentHeader);
977
1024
  const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
1025
+ this.lastPaidUsd = paymentAmountToUsd(details.amount);
1026
+ appendSettlementRow('/v1/messages', this.lastPaidUsd, {
1027
+ model,
1028
+ wallet: wallet.address,
1029
+ network: details.network || 'solana-mainnet',
1030
+ client_kind: 'AgentClient',
1031
+ });
978
1032
  const secretBytes = await solanaKeyToBytes(wallet.privateKey);
979
1033
  const feePayer = details.extra?.feePayer || details.recipient;
980
1034
  const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
@@ -1543,16 +1543,25 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1543
1543
  contextPct: Math.round(contextUsagePct),
1544
1544
  });
1545
1545
  // Record usage for stats tracking (franklin stats command).
1546
+ // Prefer the real x402 charge from the gateway over a token-catalog
1547
+ // estimate. The estimate is wrong any time the gateway applies
1548
+ // promo pricing, prompt-cache discounts, or per-call flat fees
1549
+ // (verified 2026-05-09 against cost_log.jsonl: token-based
1550
+ // estimate said $34.79 across the same calls the wallet only
1551
+ // paid $2.24 for — a 15× drift). estimateCost only fills in
1552
+ // when no payment was made (free model / cached / pre-stream
1553
+ // failure), where the gateway charge is genuinely 0.
1554
+ //
1546
1555
  // Pass the fallback flag so franklin-stats.json's totalFallbacks +
1547
1556
  // per-model fallbackCount stay in sync with the audit log a few
1548
1557
  // lines below — same `turnFailedModels.size > 0` predicate, same
1549
- // turn. Without this, stats showed 0 fallbacks across 5150 real
1550
- // requests on a machine that visibly hit fallback paths in
1551
- // franklin-debug.log; `franklin insights` was therefore useless
1552
- // for spotting a hot routing chain.
1553
- const costEstimate = estimateCost(resolvedModel, inputTokens, usage.outputTokens, 1);
1558
+ // turn.
1559
+ const paidUsd = client.getLastPaidUsd();
1560
+ const callCost = paidUsd > 0
1561
+ ? paidUsd
1562
+ : estimateCost(resolvedModel, inputTokens, usage.outputTokens, 1);
1554
1563
  const llmLatencyMs = Date.now() - llmCallStartedAt;
1555
- recordUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, llmLatencyMs, turnFailedModels.size > 0);
1564
+ recordUsage(resolvedModel, inputTokens, usage.outputTokens, callCost, llmLatencyMs, turnFailedModels.size > 0);
1556
1565
  // ── Circuit breakers: prevent infinite-loop wallet drain ──
1557
1566
  // Per-turn $-cap was removed in v3.11.0 — runaway loops are caught by
1558
1567
  // MAX_TOOL_CALLS_PER_TURN (25) and MAX_TINY_RESPONSES (2) above; the
@@ -1579,7 +1588,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1579
1588
  else {
1580
1589
  consecutiveTinyResponses = 0;
1581
1590
  }
1582
- recordSessionUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, routingTier);
1591
+ recordSessionUsage(resolvedModel, inputTokens, usage.outputTokens, callCost, routingTier);
1583
1592
  // Capture tool names invoked in this assistant turn. The AuditEntry
1584
1593
  // interface has had a `toolCalls?: string[]` slot since 3.15.11, but
1585
1594
  // nothing populated it — verified 2026-05-04 in a real Opus session
@@ -1602,7 +1611,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1602
1611
  model: resolvedModel,
1603
1612
  inputTokens,
1604
1613
  outputTokens: usage.outputTokens,
1605
- costUsd: costEstimate,
1614
+ costUsd: callCost,
1606
1615
  // Any failed model this turn means the model that finally
1607
1616
  // succeeded was a fallback. Without this, audit log read 0%
1608
1617
  // fallbacks across 4k entries — useless for diagnosing whether
@@ -1617,11 +1626,11 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1617
1626
  // Accumulate session-level totals for session meta
1618
1627
  sessionInputTokens += inputTokens;
1619
1628
  sessionOutputTokens += usage.outputTokens;
1620
- sessionCostUsd += costEstimate;
1621
- turnCostUsd += costEstimate;
1629
+ sessionCostUsd += callCost;
1630
+ turnCostUsd += callCost;
1622
1631
  const opusCost = (inputTokens / 1_000_000) * OPUS_PRICING.input
1623
1632
  + (usage.outputTokens / 1_000_000) * OPUS_PRICING.output;
1624
- sessionSavedVsOpus += Math.max(0, opusCost - costEstimate);
1633
+ sessionSavedVsOpus += Math.max(0, opusCost - callCost);
1625
1634
  // ── Max-spend guard ──
1626
1635
  // Session-level cost ceiling. Batch/scripted callers pass this to bound a
1627
1636
  // single run ("spend at most $0.50 for today's digest"); interactive
@@ -77,33 +77,68 @@ export function budgetToolResults(history) {
77
77
  budgeted.push(part);
78
78
  continue;
79
79
  }
80
- const content = typeof part.content === 'string' ? part.content : JSON.stringify(part.content);
81
- const size = content.length;
82
- // Per-tool cap
80
+ // Decompose tool_result content. Two shapes are valid per
81
+ // CapabilityOutcome (types.ts:38): a bare string OR an array of
82
+ // text + image segments. Pre-fix, we collapsed array content to
83
+ // JSON.stringify(content), which made base64 image bytes count
84
+ // toward the char budget — a 275KB image would tip past the 32K
85
+ // cap, the whole content array (including the image block) got
86
+ // replaced with a truncated text preview, and the image was
87
+ // destroyed before reaching the wire. Verified 2026-05-10 from a
88
+ // gateway log (sonnet-4.6, ~21K input tokens — would have been
89
+ // ~150K with the image present): the tool_result body was a
90
+ // 2KB self-referential string starting with "[Output truncated:
91
+ // 275,952 chars → 2000 preview]\n\n[{\"type\":\"text\"…". Vision
92
+ // hallucinated everything in that session.
93
+ //
94
+ // Fix: only the TEXT segments count toward MAX_TOOL_RESULT_CHARS.
95
+ // Image segments pass through untouched. If text is over budget,
96
+ // truncate ONLY the text — keep the image array alongside.
97
+ const isArrayContent = Array.isArray(part.content);
98
+ const textBlocks = isArrayContent
99
+ ? part.content.filter((b) => b.type === 'text')
100
+ : [];
101
+ const imageBlocks = isArrayContent
102
+ ? part.content.filter((b) => b.type === 'image')
103
+ : [];
104
+ const textOnly = isArrayContent
105
+ ? textBlocks.map(b => b.text).join('\n')
106
+ : part.content;
107
+ const size = textOnly.length;
108
+ // Per-tool cap (text-only — images stay)
83
109
  if (size > MAX_TOOL_RESULT_CHARS) {
84
110
  modified = true;
85
111
  // Truncate at line boundary for cleaner output
86
- let preview = content.slice(0, PREVIEW_CHARS);
112
+ let preview = textOnly.slice(0, PREVIEW_CHARS);
87
113
  const lastNewline = preview.lastIndexOf('\n');
88
114
  if (lastNewline > PREVIEW_CHARS * 0.5) {
89
115
  preview = preview.slice(0, lastNewline);
90
116
  }
117
+ const truncatedText = `[Output truncated: ${size.toLocaleString()} chars → ${PREVIEW_CHARS} preview]\n\n${preview}\n\n... (${size - PREVIEW_CHARS} chars omitted)`;
91
118
  budgeted.push({
92
119
  type: 'tool_result',
93
120
  tool_use_id: part.tool_use_id,
94
- content: `[Output truncated: ${size.toLocaleString()} chars → ${PREVIEW_CHARS} preview]\n\n${preview}\n\n... (${size - PREVIEW_CHARS} chars omitted)`,
121
+ content: imageBlocks.length > 0
122
+ ? [{ type: 'text', text: truncatedText }, ...imageBlocks]
123
+ : truncatedText,
95
124
  is_error: part.is_error,
96
125
  });
97
126
  messageTotal += PREVIEW_CHARS + 200;
98
127
  continue;
99
128
  }
100
- // Per-message aggregate cap — once exceeded, truncate remaining results
129
+ // Per-message aggregate cap — once exceeded, truncate remaining results.
130
+ // Same rule: drop only the text payload; images survive so multi-image
131
+ // tool flows aren't silently broken when a single chatty text result
132
+ // pushes the message over the cap.
101
133
  if (messageTotal + size > MAX_TOOL_RESULTS_PER_MESSAGE_CHARS) {
102
134
  modified = true;
135
+ const placeholder = `[Output omitted: message budget exceeded (${MAX_TOOL_RESULTS_PER_MESSAGE_CHARS / 1000}K chars/msg)]`;
103
136
  budgeted.push({
104
137
  type: 'tool_result',
105
138
  tool_use_id: part.tool_use_id,
106
- content: `[Output omitted: message budget exceeded (${MAX_TOOL_RESULTS_PER_MESSAGE_CHARS / 1000}K chars/msg)]`,
139
+ content: imageBlocks.length > 0
140
+ ? [{ type: 'text', text: placeholder }, ...imageBlocks]
141
+ : placeholder,
107
142
  is_error: part.is_error,
108
143
  });
109
144
  messageTotal = MAX_TOOL_RESULTS_PER_MESSAGE_CHARS;
@@ -2,7 +2,10 @@
2
2
  * franklin panel — launch the local web dashboard.
3
3
  */
4
4
  import chalk from 'chalk';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
5
7
  import { createPanelServer } from '../panel/server.js';
8
+ import { BLOCKRUN_DIR } from '../config.js';
6
9
  export async function panelCommand(options) {
7
10
  const requestedPort = parseInt(options.port || '3100', 10);
8
11
  // Handle port-in-use by trying up to 20 subsequent ports silently.
@@ -25,9 +28,20 @@ export async function panelCommand(options) {
25
28
  // Bind to loopback only — the panel exposes wallet secrets on /api/wallet/secret
26
29
  // and a write-capable /api/wallet/import. Never expose these on a LAN.
27
30
  server.listen(port, '127.0.0.1', () => {
31
+ const url = `http://localhost:${port}`;
32
+ // Mirror what start.ts does for the auto-panel — persist the bound
33
+ // URL so any concurrent `franklin start` agent can read /#wallet
34
+ // off the same file. Without this, a user who disables panel
35
+ // autostart and runs `franklin panel` separately would still get
36
+ // the hardcoded 3100 default in the agent prompt.
37
+ try {
38
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
39
+ fs.writeFileSync(path.join(BLOCKRUN_DIR, 'panel-url'), url, 'utf8');
40
+ }
41
+ catch { /* best-effort */ }
28
42
  console.log('');
29
43
  console.log(chalk.bold(' Franklin Panel'));
30
- console.log(chalk.dim(` http://localhost:${port}`) +
44
+ console.log(chalk.dim(` ${url}`) +
31
45
  (port !== requestedPort ? chalk.yellow(` (fell back from ${requestedPort})`) : ''));
32
46
  console.log('');
33
47
  console.log(chalk.dim(' Press Ctrl+C to stop.'));
@@ -35,7 +49,7 @@ export async function panelCommand(options) {
35
49
  // Try to open browser
36
50
  const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
37
51
  import('node:child_process').then(({ exec }) => {
38
- exec(`${open} http://localhost:${port}`);
52
+ exec(`${open} ${url}`);
39
53
  }).catch(() => { });
40
54
  });
41
55
  // Graceful shutdown
@@ -1,6 +1,8 @@
1
1
  import chalk from 'chalk';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
2
4
  import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm';
3
- import { loadChain, API_URLS } from '../config.js';
5
+ import { BLOCKRUN_DIR, loadChain, API_URLS } from '../config.js';
4
6
  import { retryFetchBalance } from './balance-retry.js';
5
7
  import { flushStats, loadStats } from '../stats/tracker.js';
6
8
  import { OPUS_PRICING, MODEL_PRICING } from '../pricing.js';
@@ -647,7 +649,18 @@ async function startPanelBackground(startPort) {
647
649
  });
648
650
  server.listen(port, '127.0.0.1', () => {
649
651
  server.unref?.();
650
- resolve(`http://localhost:${port}`);
652
+ const url = `http://localhost:${port}`;
653
+ // Persist the bound URL so the agent context (assembled per-turn)
654
+ // can point users at /#wallet for funding without baking in the
655
+ // 3100 default — the panel auto-increments past EADDRINUSE.
656
+ // Best-effort write: a stale file from a crashed run is harmless,
657
+ // since the user just sees a dead link.
658
+ try {
659
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
660
+ fs.writeFileSync(path.join(BLOCKRUN_DIR, 'panel-url'), url, 'utf8');
661
+ }
662
+ catch { /* best-effort */ }
663
+ resolve(url);
651
664
  });
652
665
  };
653
666
  tryListen(startPort, 0);
@@ -21,7 +21,7 @@ const VALID_CATEGORIES = new Set([
21
21
  const EXTRACTION_PROMPT = `You are analyzing a conversation between a user and an AI coding agent. Extract user preferences, behavioral patterns, and project knowledge that would help personalize future interactions.
22
22
 
23
23
  Analyze for:
24
- 1. Language — what language does the user write in? (English, Chinese, mixed?)
24
+ 1. Language — what language does the user write in? (English, another language, mixed?)
25
25
  2. Model preferences — did they switch models or express a preference?
26
26
  3. Coding style — did they correct the agent's code style? (naming, formatting, conventions)
27
27
  4. Communication — are they terse or verbose? Do they want explanations or just code?
@@ -1,6 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
3
3
  import { recordUsage } from '../stats/tracker.js';
4
+ import { appendSettlementRow } from '../stats/cost-log.js';
4
5
  import { appendAudit } from '../stats/audit.js';
5
6
  import { buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
6
7
  import { routeRequest, parseRoutingProfile, } from '../router/index.js';
@@ -430,6 +431,11 @@ export function createProxy(options) {
430
431
  };
431
432
  let response;
432
433
  let finalModel = requestModel;
434
+ // Real x402 charge for the call that ultimately succeeded. 0 when
435
+ // no payment was needed (free model / cached). Fed into recordUsage
436
+ // and appendAudit below so franklin-stats.json reflects what the
437
+ // wallet actually paid, not a token-catalog estimate.
438
+ let paidUsd = 0;
433
439
  const requestTimeoutMs = effectiveRequestTimeoutMs;
434
440
  // Use fallback chain if enabled
435
441
  if (fallbackEnabled && body && requestPath.includes('messages')) {
@@ -457,6 +463,7 @@ export function createProxy(options) {
457
463
  // Use the body with the correct fallback model for payment
458
464
  body = result.bodyUsed;
459
465
  usedFallback = result.fallbackUsed;
466
+ paidUsd = result.paidUsd;
460
467
  // Skip the success log when the request originated from a test
461
468
  // fixture, even if the fallback ended on a real model. Verified
462
469
  // on a real machine: 5 spurious "↺ Fallback successful: using
@@ -473,7 +480,7 @@ export function createProxy(options) {
473
480
  }
474
481
  }
475
482
  else {
476
- response = await fetchModelAttempt(targetUrl, requestInit, body, requestModel, {
483
+ const attempt = await fetchModelAttempt(targetUrl, requestInit, body, requestModel, {
477
484
  method: req.method || 'POST',
478
485
  headers,
479
486
  chain,
@@ -481,6 +488,8 @@ export function createProxy(options) {
481
488
  solanaWallet,
482
489
  timeoutMs: requestTimeoutMs,
483
490
  });
491
+ response = attempt.response;
492
+ paidUsd = attempt.paidUsd;
484
493
  }
485
494
  const responseHeaders = {};
486
495
  response.headers.forEach((v, k) => {
@@ -569,7 +578,13 @@ export function createProxy(options) {
569
578
  if (outputTokens > 0) {
570
579
  trackOutputTokens(finalModel, outputTokens);
571
580
  const latencyMs = Date.now() - requestStartTime;
572
- const cost = estimateCost(finalModel, inputTokens, outputTokens);
581
+ // Real x402 charge wins over the token-catalog estimate.
582
+ // estimateCost only fills in for the no-payment path
583
+ // (free models / cached) so stats stay non-null there.
584
+ const cost = paidUsd > 0
585
+ ? paidUsd
586
+ : estimateCost(finalModel, inputTokens, outputTokens);
587
+ const costSource = paidUsd > 0 ? 'charged' : 'estimated';
573
588
  recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
574
589
  appendAudit({
575
590
  ts: Date.now(),
@@ -582,7 +597,7 @@ export function createProxy(options) {
582
597
  source: 'proxy',
583
598
  });
584
599
  if (options.debug)
585
- logger.debug(`[franklin] recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
600
+ logger.debug(`[franklin] recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} (${costSource}) fallback=${usedFallback}`);
586
601
  }
587
602
  }
588
603
  res.end();
@@ -609,7 +624,10 @@ export function createProxy(options) {
609
624
  trackOutputTokens(finalModel, outputTokens);
610
625
  const inputTokens = parsed.usage?.input_tokens || 0;
611
626
  const latencyMs = Date.now() - requestStartTime;
612
- const cost = estimateCost(finalModel, inputTokens, outputTokens);
627
+ const cost = paidUsd > 0
628
+ ? paidUsd
629
+ : estimateCost(finalModel, inputTokens, outputTokens);
630
+ const costSource = paidUsd > 0 ? 'charged' : 'estimated';
613
631
  recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
614
632
  appendAudit({
615
633
  ts: Date.now(),
@@ -622,7 +640,7 @@ export function createProxy(options) {
622
640
  source: 'proxy',
623
641
  });
624
642
  if (options.debug)
625
- logger.debug(`[franklin] recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
643
+ logger.debug(`[franklin] recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} (${costSource}) fallback=${usedFallback}`);
626
644
  }
627
645
  }
628
646
  catch {
@@ -645,16 +663,17 @@ export function createProxy(options) {
645
663
  return server;
646
664
  }
647
665
  async function fetchModelAttempt(url, init, body, model, payment) {
648
- let response = await fetchWithTimeout(url, { ...init, body: body || undefined }, payment.timeoutMs, `Proxy request for ${model}`);
666
+ const response = await fetchWithTimeout(url, { ...init, body: body || undefined }, payment.timeoutMs, `Proxy request for ${model}`);
667
+ // Non-402 path: free model or cached response — no payment, paidUsd = 0.
649
668
  if (response.status !== 402)
650
- return response;
669
+ return { response, paidUsd: 0 };
651
670
  if (payment.chain === 'solana' && payment.solanaWallet) {
652
671
  return handleSolanaPayment(response, url, payment.method, payment.headers, body, payment.solanaWallet.privateKey, payment.solanaWallet.address, payment.timeoutMs, model);
653
672
  }
654
673
  if (payment.baseWallet) {
655
674
  return handleBasePayment(response, url, payment.method, payment.headers, body, payment.baseWallet.privateKey, payment.baseWallet.address, payment.timeoutMs, model);
656
675
  }
657
- return response;
676
+ return { response, paidUsd: 0 };
658
677
  }
659
678
  /**
660
679
  * Try each fallback model as a full x402 attempt:
@@ -670,7 +689,7 @@ async function fetchWithPaymentFallback(url, init, originalBody, config, payment
670
689
  const body = replaceModelInBody(originalBody, model);
671
690
  try {
672
691
  attempts++;
673
- const response = await fetchModelAttempt(url, init, body, model, payment);
692
+ const { response, paidUsd } = await fetchModelAttempt(url, init, body, model, payment);
674
693
  if (!config.retryOn.includes(response.status)) {
675
694
  return {
676
695
  response,
@@ -679,6 +698,7 @@ async function fetchWithPaymentFallback(url, init, originalBody, config, payment
679
698
  fallbackUsed: i > 0,
680
699
  attemptsCount: attempts,
681
700
  failedModels,
701
+ paidUsd,
682
702
  };
683
703
  }
684
704
  try {
@@ -719,17 +739,24 @@ function sleep(ms) {
719
739
  async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
720
740
  const paymentHeader = await extractPaymentHeader(response);
721
741
  if (!paymentHeader) {
722
- throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
742
+ throw new Error('402 Payment Required — wallet may need funding. Open http://localhost:3100/#wallet to deposit USDC (or run: franklin balance)');
723
743
  }
724
744
  const paymentRequired = parsePaymentRequired(paymentHeader);
725
745
  const details = extractPaymentDetails(paymentRequired);
746
+ const paidUsd = paymentAmountToUsd(details.amount);
747
+ appendSettlementRow(extractEndpointPath(url), paidUsd, {
748
+ model,
749
+ wallet: fromAddress,
750
+ network: details.network || 'base-mainnet',
751
+ client_kind: 'ProxyClient',
752
+ });
726
753
  const paymentPayload = await createPaymentPayload(privateKey, fromAddress, details.recipient, details.amount, details.network || 'eip155:8453', {
727
754
  resourceUrl: details.resource?.url || url,
728
755
  resourceDescription: details.resource?.description || 'BlockRun AI API call',
729
756
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
730
757
  extra: details.extra,
731
758
  });
732
- return fetchWithTimeout(url, {
759
+ const paid = await fetchWithTimeout(url, {
733
760
  method,
734
761
  headers: {
735
762
  ...headers,
@@ -737,6 +764,7 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
737
764
  },
738
765
  body: body || undefined,
739
766
  }, timeoutMs, `Paid proxy request for ${model}`);
767
+ return { response: paid, paidUsd };
740
768
  }
741
769
  // ======================================================================
742
770
  // Solana payment handler
@@ -744,10 +772,17 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
744
772
  async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
745
773
  const paymentHeader = await extractPaymentHeader(response);
746
774
  if (!paymentHeader) {
747
- throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
775
+ throw new Error('402 Payment Required — wallet may need funding. Open http://localhost:3100/#wallet to deposit USDC (or run: franklin balance)');
748
776
  }
749
777
  const paymentRequired = parsePaymentRequired(paymentHeader);
750
778
  const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
779
+ const paidUsd = paymentAmountToUsd(details.amount);
780
+ appendSettlementRow(extractEndpointPath(url), paidUsd, {
781
+ model,
782
+ wallet: fromAddress,
783
+ network: details.network || 'solana-mainnet',
784
+ client_kind: 'ProxyClient',
785
+ });
751
786
  const secretKey = await solanaKeyToBytes(privateKey);
752
787
  const feePayer = details.extra?.feePayer || details.recipient;
753
788
  const paymentPayload = await createSolanaPaymentPayload(secretKey, fromAddress, details.recipient, details.amount, feePayer, {
@@ -756,7 +791,7 @@ async function handleSolanaPayment(response, url, method, headers, body, private
756
791
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
757
792
  extra: details.extra,
758
793
  });
759
- return fetchWithTimeout(url, {
794
+ const paid = await fetchWithTimeout(url, {
760
795
  method,
761
796
  headers: {
762
797
  ...headers,
@@ -764,6 +799,35 @@ async function handleSolanaPayment(response, url, method, headers, body, private
764
799
  },
765
800
  body: body || undefined,
766
801
  }, timeoutMs, `Paid proxy request for ${model}`);
802
+ return { response: paid, paidUsd };
803
+ }
804
+ /**
805
+ * Extract just the path portion of a URL — `https://api.blockrun.ai/v1/messages`
806
+ * → `/v1/messages`. Used as the `endpoint` field in `cost_log.jsonl` so
807
+ * proxy entries match the SDK's path-only convention. Falls back to the
808
+ * raw input if URL parsing throws (defensive — better to log a weird
809
+ * string than skip the row).
810
+ */
811
+ function extractEndpointPath(url) {
812
+ try {
813
+ return new URL(url).pathname || url;
814
+ }
815
+ catch {
816
+ return url;
817
+ }
818
+ }
819
+ /**
820
+ * Convert an x402 `details.amount` field (USDC in micro-units, 6 decimals)
821
+ * to a USD float. Mirrors the SDK's `appendCostLog` math so the proxy and
822
+ * `cost_log.jsonl` agree to the cent.
823
+ */
824
+ function paymentAmountToUsd(amount) {
825
+ if (amount === undefined || amount === null)
826
+ return 0;
827
+ const n = typeof amount === 'string' ? parseFloat(amount) : amount;
828
+ if (!Number.isFinite(n))
829
+ return 0;
830
+ return n / 1e6;
767
831
  }
768
832
  export function classifyRequest(body) {
769
833
  try {
@@ -51,4 +51,4 @@ export declare function extractArticleBlocks(tree: string): Array<{
51
51
  * This doubles as the "this is a tweet" signal in social-bot — the only link
52
52
  * inside an article block with this label shape is the permalink to the tweet.
53
53
  */
54
- export declare const X_TIME_LINK_PATTERN = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}\\u5e74\\d{1,2}\\u6708\\d{1,2}\\u65e5";
54
+ export declare const X_TIME_LINK_PATTERN = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}\\p{Script=Han}\\d{1,2}\\p{Script=Han}\\d{1,2}\\p{Script=Han}";
@@ -23,7 +23,7 @@
23
23
  * @returns Array of ref ids like ["0-0", "1-3"] in document order
24
24
  */
25
25
  export function findRefs(tree, role, label = '.*') {
26
- const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*${label}`, 'g');
26
+ const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(?:${label})`, 'gu');
27
27
  const out = [];
28
28
  let m;
29
29
  while ((m = re.exec(tree)) !== null) {
@@ -36,7 +36,7 @@ export function findRefs(tree, role, label = '.*') {
36
36
  * (ref) and the visible text (label) in one pass.
37
37
  */
38
38
  export function findRefsWithLabels(tree, role, label = '.*') {
39
- const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(${label})`, 'g');
39
+ const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(${label})`, 'gu');
40
40
  const out = [];
41
41
  let m;
42
42
  while ((m = re.exec(tree)) !== null) {
@@ -87,11 +87,8 @@ export function extractArticleBlocks(tree) {
87
87
  // "Mar 16", "Apr 12, 2026", "5h", "5m", "2d", "30s", "just now", "now"
88
88
  // "31 seconds ago", "35 minutes ago", "4 hours ago" (full-word format)
89
89
  // "Yesterday", "Apr 12", "12:30 AM"
90
- // CJK-locale date markers (year/month/day in Chinese-locale rendering of
91
- // tweet timestamps). Encoded via Unicode escapes to keep the source file
92
- // ASCII-clean per the English-only-source policy:
93
- // U+5E74 = year marker, U+6708 = month marker, U+65E5 = day marker.
94
- export const X_TIME_LINK_PATTERN = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}\\u5e74\\d{1,2}\\u6708\\d{1,2}\\u65e5';
90
+ // Locale-rendered numeric dates separated by Han-script date markers
91
+ export const X_TIME_LINK_PATTERN = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}\\p{Script=Han}\\d{1,2}\\p{Script=Han}\\d{1,2}\\p{Script=Han}';
95
92
  function escapeRegex(s) {
96
93
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
94
  }
@@ -78,6 +78,54 @@ export function serializeAxTree(root) {
78
78
  walk(root, 0);
79
79
  return { tree: lines.join('\n'), refs };
80
80
  }
81
+ function cdpStringValue(v) {
82
+ if (v === undefined || v === null)
83
+ return '';
84
+ if (typeof v === 'string')
85
+ return v;
86
+ return String(v);
87
+ }
88
+ function cdpNodesToAxTree(nodes) {
89
+ if (!nodes || nodes.length === 0)
90
+ return null;
91
+ const byId = new Map();
92
+ const childSet = new Set();
93
+ for (const n of nodes) {
94
+ byId.set(n.nodeId, n);
95
+ if (n.childIds)
96
+ for (const cid of n.childIds)
97
+ childSet.add(cid);
98
+ }
99
+ // The root has no parent (or no entry pointing at it as a child).
100
+ const root = nodes.find((n) => !n.parentId && !childSet.has(n.nodeId)) ??
101
+ nodes.find((n) => !n.parentId) ??
102
+ nodes[0];
103
+ const seen = new Set();
104
+ function build(node) {
105
+ if (seen.has(node.nodeId))
106
+ return null;
107
+ seen.add(node.nodeId);
108
+ const ax = {
109
+ role: cdpStringValue(node.role?.value),
110
+ name: cdpStringValue(node.name?.value),
111
+ value: cdpStringValue(node.value?.value),
112
+ description: cdpStringValue(node.description?.value),
113
+ children: [],
114
+ };
115
+ if (node.childIds) {
116
+ for (const cid of node.childIds) {
117
+ const child = byId.get(cid);
118
+ if (!child)
119
+ continue;
120
+ const built = build(child);
121
+ if (built)
122
+ ax.children.push(built);
123
+ }
124
+ }
125
+ return ax;
126
+ }
127
+ return build(root);
128
+ }
81
129
  /**
82
130
  * Franklin's social browser driver. Lazy-imports playwright-core so the
83
131
  * rest of the CLI stays fast to start.
@@ -144,10 +192,21 @@ export class SocialBrowser {
144
192
  */
145
193
  async snapshot() {
146
194
  this.requirePage();
147
- // Playwright's accessibility snapshot returns a full AX tree
148
- // page.accessibility was removed from Playwright types in v1.46 but still works at runtime
149
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
- const axRoot = await this.page.accessibility.snapshot({ interestingOnly: false });
195
+ // page.accessibility was removed from playwright-core (gone by 1.59).
196
+ // Calling it threw `Cannot read properties of undefined (reading 'snapshot')`
197
+ // in production (failures.jsonl entries 1776662596215 / 1776662608060).
198
+ // The supported replacement is the CDP Accessibility domain, which still
199
+ // ships with Chromium-based browsers.
200
+ const cdp = await this.page.context().newCDPSession(this.page);
201
+ let axRoot;
202
+ try {
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ const result = (await cdp.send('Accessibility.getFullAXTree'));
205
+ axRoot = cdpNodesToAxTree(result?.nodes);
206
+ }
207
+ finally {
208
+ await cdp.detach().catch(() => { });
209
+ }
151
210
  if (!axRoot)
152
211
  return '';
153
212
  const { tree, refs } = serializeAxTree(axRoot);
@@ -1,24 +1,26 @@
1
1
  /**
2
- * Reader for `~/.blockrun/cost_log.jsonl` — the SDK-owned ledger of every
3
- * settled x402 payment.
2
+ * Reader (and limited writer) for `~/.blockrun/cost_log.jsonl` — the
3
+ * append-only ledger of every settled x402 payment.
4
4
  *
5
- * Franklin's own `franklin-stats.json` and `franklin-audit.jsonl` only
6
- * capture calls that pass through specific code paths (the main agent
7
- * loop and the proxy). Helper LLM calls (analyzeTurn, prefetchForIntent,
8
- * compaction, evaluator, verification, MoA, subagent, learning extraction,
9
- * etc.) all settle x402 payments through the SDK those payments DO get
10
- * recorded in cost_log.jsonl by `@blockrun/llm` itself, but Franklin's
11
- * stats infra had been ignoring this file entirely.
5
+ * History: this file was originally SDK-only territory. `@blockrun/llm`'s
6
+ * internal `appendCostLog` writes one line per micropayment when callers
7
+ * use SDK helper methods (modal sandbox, prediction market, exa, etc.).
8
+ * But Franklin's main LLM stream both the in-process agent loop
9
+ * (`src/agent/llm.ts`) and the proxy server (`src/proxy/server.ts`)
10
+ * have **their own** x402 signers that bypass the SDK entirely. Verified
11
+ * 2026-05-09 on a real machine: a single paid agent turn dropped the
12
+ * wallet by $0.001 and updated `franklin-stats.json` correctly, but
13
+ * cost_log.jsonl gained zero entries. So cost_log was never the
14
+ * "wallet truth" it advertised — it was an SDK-subset.
12
15
  *
13
- * Verified 2026-05-06 against a real machine: cost_log.jsonl is written
14
- * by the SDK with snake_case keys (`cost_usd`, `ts` in unix seconds with
15
- * subsecond precision Python convention) and Franklin's reads/writes
16
- * use camelCase + ms. This module bridges the format gap so stats /
17
- * insights / `franklin balance` can surface the wallet-truth total
18
- * alongside the recorded total.
16
+ * Fix (2026-05-09): expose `appendSettlementRow` so the agent and proxy
17
+ * signers can write the same shape the SDK does. The format contract
18
+ * (snake_case `cost_usd`, `ts` in unix seconds with subsecond precision,
19
+ * one JSON object per line) is preserved exactly so both writers
20
+ * interleave cleanly. Order in the file follows wall-clock arrival.
19
21
  *
20
- * Responsibility: read-only. We never write or trim cost_log.jsonl —
21
- * the SDK owns it.
22
+ * Responsibility: read + append-only write. We never trim or rotate
23
+ * cost_log.jsonl — that contract still belongs to the SDK / hygiene.
22
24
  */
23
25
  export interface SettlementRow {
24
26
  /** Endpoint path that was paid for, e.g. `/v1/chat/completions`. */
@@ -61,6 +63,39 @@ interface ReadOptions {
61
63
  * is only created on the first paid call.
62
64
  */
63
65
  export declare function loadSdkSettlements(opts?: ReadOptions): SettlementRow[];
66
+ /**
67
+ * Optional metadata fields the SDK writes alongside `endpoint` / `cost_usd`.
68
+ * Adding these to agent + proxy entries keeps cost_log.jsonl uniformly
69
+ * queryable (group by model, filter by wallet, etc.). Verified 2026-05-10
70
+ * against a real cost_log: the SDK writes
71
+ * {endpoint, cost_usd, model, wallet, network, client_kind}
72
+ * Without these on agent rows you can't tell which model burned a $0.001
73
+ * — the row is just `/v1/messages: 0.001`. With them, every line is a
74
+ * complete forensic record.
75
+ */
76
+ export interface SettlementMeta {
77
+ model?: string;
78
+ wallet?: string;
79
+ network?: string;
80
+ client_kind?: string;
81
+ }
82
+ /**
83
+ * Append one settlement row to ~/.blockrun/cost_log.jsonl in the same
84
+ * shape `@blockrun/llm`'s internal `appendCostLog` writes. Best-effort:
85
+ * silently swallows fs errors so a logging failure never breaks the
86
+ * paid call that just succeeded. Costs <= 0 are treated as no-op (no
87
+ * point logging $0 — the file's purpose is "what was actually paid").
88
+ *
89
+ * Honors FRANKLIN_NO_AUDIT=1 the same way `appendAudit` and `recordUsage`
90
+ * do, so test runs (test/e2e.mjs sets this) don't pollute the user's
91
+ * real cost_log. Verified 2026-05-10 on a real machine: two
92
+ * `/v1/messages: $0.000001` rows leaked into the user's cost_log from
93
+ * a paid e2e run because this gate was missing — paid e2e was hitting
94
+ * the real gateway with a real wallet, but the test framework expected
95
+ * NO writes to land. Restoring the gate keeps cost_log a clean ledger
96
+ * of REAL traffic.
97
+ */
98
+ export declare function appendSettlementRow(endpoint: string, costUsd: number, meta?: SettlementMeta): void;
64
99
  /** Aggregate the SDK ledger into a single summary object. */
65
100
  export declare function summarizeSdkSettlements(opts?: ReadOptions): SettlementSummary;
66
101
  export {};
@@ -1,24 +1,26 @@
1
1
  /**
2
- * Reader for `~/.blockrun/cost_log.jsonl` — the SDK-owned ledger of every
3
- * settled x402 payment.
2
+ * Reader (and limited writer) for `~/.blockrun/cost_log.jsonl` — the
3
+ * append-only ledger of every settled x402 payment.
4
4
  *
5
- * Franklin's own `franklin-stats.json` and `franklin-audit.jsonl` only
6
- * capture calls that pass through specific code paths (the main agent
7
- * loop and the proxy). Helper LLM calls (analyzeTurn, prefetchForIntent,
8
- * compaction, evaluator, verification, MoA, subagent, learning extraction,
9
- * etc.) all settle x402 payments through the SDK those payments DO get
10
- * recorded in cost_log.jsonl by `@blockrun/llm` itself, but Franklin's
11
- * stats infra had been ignoring this file entirely.
5
+ * History: this file was originally SDK-only territory. `@blockrun/llm`'s
6
+ * internal `appendCostLog` writes one line per micropayment when callers
7
+ * use SDK helper methods (modal sandbox, prediction market, exa, etc.).
8
+ * But Franklin's main LLM stream both the in-process agent loop
9
+ * (`src/agent/llm.ts`) and the proxy server (`src/proxy/server.ts`)
10
+ * have **their own** x402 signers that bypass the SDK entirely. Verified
11
+ * 2026-05-09 on a real machine: a single paid agent turn dropped the
12
+ * wallet by $0.001 and updated `franklin-stats.json` correctly, but
13
+ * cost_log.jsonl gained zero entries. So cost_log was never the
14
+ * "wallet truth" it advertised — it was an SDK-subset.
12
15
  *
13
- * Verified 2026-05-06 against a real machine: cost_log.jsonl is written
14
- * by the SDK with snake_case keys (`cost_usd`, `ts` in unix seconds with
15
- * subsecond precision Python convention) and Franklin's reads/writes
16
- * use camelCase + ms. This module bridges the format gap so stats /
17
- * insights / `franklin balance` can surface the wallet-truth total
18
- * alongside the recorded total.
16
+ * Fix (2026-05-09): expose `appendSettlementRow` so the agent and proxy
17
+ * signers can write the same shape the SDK does. The format contract
18
+ * (snake_case `cost_usd`, `ts` in unix seconds with subsecond precision,
19
+ * one JSON object per line) is preserved exactly so both writers
20
+ * interleave cleanly. Order in the file follows wall-clock arrival.
19
21
  *
20
- * Responsibility: read-only. We never write or trim cost_log.jsonl —
21
- * the SDK owns it.
22
+ * Responsibility: read + append-only write. We never trim or rotate
23
+ * cost_log.jsonl — that contract still belongs to the SDK / hygiene.
22
24
  */
23
25
  import fs from 'node:fs';
24
26
  import path from 'node:path';
@@ -79,6 +81,54 @@ export function loadSdkSettlements(opts) {
79
81
  }
80
82
  return rows;
81
83
  }
84
+ /**
85
+ * Append one settlement row to ~/.blockrun/cost_log.jsonl in the same
86
+ * shape `@blockrun/llm`'s internal `appendCostLog` writes. Best-effort:
87
+ * silently swallows fs errors so a logging failure never breaks the
88
+ * paid call that just succeeded. Costs <= 0 are treated as no-op (no
89
+ * point logging $0 — the file's purpose is "what was actually paid").
90
+ *
91
+ * Honors FRANKLIN_NO_AUDIT=1 the same way `appendAudit` and `recordUsage`
92
+ * do, so test runs (test/e2e.mjs sets this) don't pollute the user's
93
+ * real cost_log. Verified 2026-05-10 on a real machine: two
94
+ * `/v1/messages: $0.000001` rows leaked into the user's cost_log from
95
+ * a paid e2e run because this gate was missing — paid e2e was hitting
96
+ * the real gateway with a real wallet, but the test framework expected
97
+ * NO writes to land. Restoring the gate keeps cost_log a clean ledger
98
+ * of REAL traffic.
99
+ */
100
+ export function appendSettlementRow(endpoint, costUsd, meta) {
101
+ if (process.env.FRANKLIN_NO_AUDIT === '1' || process.env.FRANKLIN_NO_PERSIST === '1')
102
+ return;
103
+ if (!Number.isFinite(costUsd) || costUsd <= 0)
104
+ return;
105
+ if (typeof endpoint !== 'string' || endpoint.length === 0)
106
+ return;
107
+ try {
108
+ fs.mkdirSync(path.dirname(getCostLogPath()), { recursive: true });
109
+ }
110
+ catch { /* best-effort */ }
111
+ // Match SDK conventions exactly: snake_case keys, ts in unix seconds
112
+ // with subsecond precision (Python convention — divide ms epoch by 1e3
113
+ // so the SDK reader and our reader agree on the timestamp).
114
+ const entry = {
115
+ ts: Date.now() / 1e3,
116
+ endpoint,
117
+ cost_usd: costUsd,
118
+ };
119
+ if (meta?.model)
120
+ entry.model = meta.model;
121
+ if (meta?.wallet)
122
+ entry.wallet = meta.wallet;
123
+ if (meta?.network)
124
+ entry.network = meta.network;
125
+ if (meta?.client_kind)
126
+ entry.client_kind = meta.client_kind;
127
+ try {
128
+ fs.appendFileSync(getCostLogPath(), JSON.stringify(entry) + '\n');
129
+ }
130
+ catch { /* best-effort */ }
131
+ }
82
132
  /** Aggregate the SDK ledger into a single summary object. */
83
133
  export function summarizeSdkSettlements(opts) {
84
134
  const rows = loadSdkSettlements(opts);
@@ -767,7 +767,7 @@ export const predictionMarketCapability = {
767
767
  'Default routing: ' +
768
768
  '"is there a market on X anywhere" → searchAll. ' +
769
769
  '"top wallets / who is profitable / who should I follow on Polymarket" → leaderboard. ' +
770
- '"analyze this wallet / can I copy this trader / 复制交易 / show me their P&L AND positions" → run walletProfile + walletPnl + walletPositions IN PARALLEL with the same address — three $0.005 calls give the full picture for $0.015. Do not Bash-curl Polymarket directly; the agent has paid tools for this. ' +
770
+ '"analyze this wallet / can I copy this trader / copy trade / show me their P&L AND positions" → run walletProfile + walletPnl + walletPositions IN PARALLEL with the same address — three $0.005 calls give the full picture for $0.015. Do not Bash-curl Polymarket directly; the agent has paid tools for this. ' +
771
771
  '"what are smart traders betting on right now" → smartActivity. ' +
772
772
  '"show smart money on this specific Polymarket market" → smartMoney with conditionId. ' +
773
773
  '"should I bet on X" → run searchPolymarket + searchKalshi in parallel and compare implied probabilities — divergence is the signal.',
package/dist/ui/app.js CHANGED
@@ -309,7 +309,7 @@ function InputBox({ input, setInput, onSubmit, model, balance, chain, walletTail
309
309
  const m = balance.match(/\$([\d.]+)/);
310
310
  const num = m ? parseFloat(m[1]) : null;
311
311
  if (num !== null && num < 0.50) {
312
- return _jsxs(_Fragment, { children: [_jsx(Text, { color: "red", bold: true, children: balance }), _jsx(Text, { color: "red", children: " \u26A0 low \u2014 fund wallet or /model free" })] });
312
+ return _jsxs(_Fragment, { children: [_jsx(Text, { color: "red", bold: true, children: balance }), _jsx(Text, { color: "red", children: " \u26A0 low \u2014 deposit at http://localhost:3100/#wallet or /model free" })] });
313
313
  }
314
314
  if (num !== null && num < 1.00) {
315
315
  return _jsx(Text, { color: "yellow", children: balance });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.88",
3
+ "version": "3.15.89",
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": {