@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.
- package/dist/agent/context.js +41 -2
- package/dist/agent/llm.d.ts +16 -0
- package/dist/agent/llm.js +62 -8
- package/dist/agent/loop.js +20 -11
- package/dist/agent/optimize.js +42 -7
- package/dist/commands/panel.js +16 -2
- package/dist/commands/start.js +15 -2
- package/dist/learnings/extractor.js +1 -1
- package/dist/proxy/server.js +77 -13
- package/dist/social/a11y.d.ts +1 -1
- package/dist/social/a11y.js +4 -7
- package/dist/social/browser.js +63 -4
- package/dist/stats/cost-log.d.ts +52 -17
- package/dist/stats/cost-log.js +67 -17
- package/dist/tools/prediction.debug.js +1 -1
- package/dist/ui/app.js +1 -1
- package/package.json +1 -1
package/dist/agent/context.js
CHANGED
|
@@ -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
|
|
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)
|
package/dist/agent/llm.d.ts
CHANGED
|
@@ -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.
|
|
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, {
|
package/dist/agent/loop.js
CHANGED
|
@@ -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.
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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 +=
|
|
1621
|
-
turnCostUsd +=
|
|
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 -
|
|
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
|
package/dist/agent/optimize.js
CHANGED
|
@@ -77,33 +77,68 @@ export function budgetToolResults(history) {
|
|
|
77
77
|
budgeted.push(part);
|
|
78
78
|
continue;
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
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 =
|
|
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:
|
|
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:
|
|
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;
|
package/dist/commands/panel.js
CHANGED
|
@@ -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(`
|
|
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}
|
|
52
|
+
exec(`${open} ${url}`);
|
|
39
53
|
}).catch(() => { });
|
|
40
54
|
});
|
|
41
55
|
// Graceful shutdown
|
package/dist/commands/start.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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?
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
package/dist/social/a11y.d.ts
CHANGED
|
@@ -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}\\
|
|
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}";
|
package/dist/social/a11y.js
CHANGED
|
@@ -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
|
|
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})`, '
|
|
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
|
-
//
|
|
91
|
-
|
|
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
|
}
|
package/dist/social/browser.js
CHANGED
|
@@ -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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
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);
|
package/dist/stats/cost-log.d.ts
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reader for `~/.blockrun/cost_log.jsonl` — the
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
21
|
-
* the SDK
|
|
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 {};
|
package/dist/stats/cost-log.js
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reader for `~/.blockrun/cost_log.jsonl` — the
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
21
|
-
* the SDK
|
|
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 /
|
|
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
|
|
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