@blockrun/franklin 3.26.0 → 3.27.0

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.
@@ -0,0 +1,767 @@
1
+ /**
2
+ * Franklin agent server (local WebSocket — drives the desktop app & browser UI).
3
+ *
4
+ * Serves the local React WebUI (franklin-webui / the desktop app) over a single
5
+ * WebSocket using the envelope wire protocol the UI already speaks:
6
+ *
7
+ * client → { id, kind, payload } (agent.send / session.* / wallet.info / …)
8
+ * server → { id, kind, payload } (agent.text / agent.step / agent.done / …)
9
+ *
10
+ * Unlike `franklin panel` (a read-only dashboard), this actually runs agent
11
+ * turns: it drives the real `interactiveSession` loop from src/agent/loop.ts —
12
+ * same tools, wallet, routing and signing as the CLI. The browser/desktop is
13
+ * just a different head on the same agent.
14
+ *
15
+ * Single-window assumption: one long-lived agent session per server process,
16
+ * fed by a getUserInput queue. Good enough for the desktop app; multi-session
17
+ * fan-out can come later.
18
+ */
19
+ import http from 'node:http';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import WebSocket from 'ws';
23
+ import { loadChain, API_URLS, BLOCKRUN_DIR } from '../config.js';
24
+ import { loadConfig, setConfigValue } from '../commands/config.js';
25
+ import { assembleInstructions } from '../agent/context.js';
26
+ import { interactiveSession } from '../agent/loop.js';
27
+ import { allCapabilities, createSubAgentCapability } from '../tools/index.js';
28
+ import { getModelsByCategory } from '../gateway-models.js';
29
+ import { listSessions, loadSessionHistory } from '../session/storage.js';
30
+ import { loadSdkSettlements } from '../stats/cost-log.js';
31
+ import { readSwaps } from '../stats/swap-log.js';
32
+ import { isCloudSyncEnabled, cloudList, cloudSync } from './cloud-sync.js';
33
+ import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm';
34
+ import { retryFetchBalance } from '../commands/balance-retry.js';
35
+ const FREE_DEFAULT_MODEL = 'nvidia/deepseek-v4-flash';
36
+ // Curated Base (chainId 8453) tokens for the wallet "holdings" view. Plain RPC
37
+ // can't enumerate every token an address holds (no on-chain "list all"), so we
38
+ // balanceOf a known set and show the non-zero ones. `stable` → USD ≈ amount.
39
+ const BASE_PUBLIC_RPCS = ['https://base.publicnode.com', 'https://mainnet.base.org', 'https://base.meowrpc.com'];
40
+ const BASE_TOKENS = [
41
+ { symbol: 'USDC', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, stable: true },
42
+ { symbol: 'USDbC', address: '0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA', decimals: 6, stable: true },
43
+ { symbol: 'USDT', address: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', decimals: 6, stable: true },
44
+ { symbol: 'DAI', address: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', decimals: 18, stable: true },
45
+ { symbol: 'WETH', address: '0x4200000000000000000000000000000000000006', decimals: 18, cg: 'ethereum' },
46
+ { symbol: 'cbBTC', address: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', decimals: 8, cg: 'bitcoin' },
47
+ { symbol: 'cbETH', address: '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22', decimals: 18, cg: 'ethereum' },
48
+ { symbol: 'AERO', address: '0x940181a94A35A4569E4529A3CDfB74e38FD98631', decimals: 18, cg: 'aerodrome-finance' },
49
+ { symbol: 'DEGEN', address: '0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed', decimals: 18, cg: 'degen-base' },
50
+ ];
51
+ // USD prices via CoinGecko (free, no key). Best-effort: on failure, tokens just
52
+ // show without a USD value rather than blocking the holdings list.
53
+ async function fetchCgPrices(ids) {
54
+ if (ids.length === 0)
55
+ return {};
56
+ try {
57
+ const url = `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent([...new Set(ids)].join(','))}&vs_currencies=usd`;
58
+ const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
59
+ const j = await r.json();
60
+ const out = {};
61
+ for (const [k, v] of Object.entries(j))
62
+ if (typeof v?.usd === 'number')
63
+ out[k] = v.usd;
64
+ return out;
65
+ }
66
+ catch {
67
+ return {};
68
+ }
69
+ }
70
+ async function baseRpc(method, params) {
71
+ for (const url of BASE_PUBLIC_RPCS) {
72
+ try {
73
+ const r = await fetch(url, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
77
+ signal: AbortSignal.timeout(6000),
78
+ });
79
+ const j = await r.json();
80
+ if (j && typeof j.result === 'string')
81
+ return j.result;
82
+ }
83
+ catch { /* try next rpc */ }
84
+ }
85
+ return null;
86
+ }
87
+ function hexToAmount(hex, decimals) {
88
+ if (!hex || hex === '0x')
89
+ return 0;
90
+ try {
91
+ const v = BigInt(hex);
92
+ if (v === 0n)
93
+ return 0;
94
+ // Scale down with enough precision for display.
95
+ return Number(v) / 10 ** decimals;
96
+ }
97
+ catch {
98
+ return 0;
99
+ }
100
+ }
101
+ /** Best-effort list of an address's holdings (native ETH + curated ERC-20s). */
102
+ async function listBaseHoldings(address) {
103
+ const out = [];
104
+ const addr = address.toLowerCase().replace(/^0x/, '').padStart(64, '0');
105
+ const DUST = 1e-6; // hide negligible dust (e.g. post-swap leftovers) that'd render as "0"
106
+ // Collect raw balances (amount + which coingecko id to price it with).
107
+ const raw = [];
108
+ const ethHex = await baseRpc('eth_getBalance', [address, 'latest']);
109
+ const eth = hexToAmount(ethHex, 18);
110
+ if (eth >= DUST)
111
+ raw.push({ symbol: 'ETH', amount: eth, cg: 'ethereum' });
112
+ await Promise.all(BASE_TOKENS.map(async (t) => {
113
+ const data = '0x70a08231' + addr; // balanceOf(address)
114
+ const hex = await baseRpc('eth_call', [{ to: t.address, data }, 'latest']);
115
+ const amt = hexToAmount(hex, t.decimals);
116
+ if (amt >= DUST)
117
+ raw.push({ symbol: t.symbol, amount: amt, stable: t.stable, cg: t.cg });
118
+ }));
119
+ // Price the non-stable holdings (stable ≈ $1) and compute USD value per token.
120
+ const prices = await fetchCgPrices(raw.filter((r) => !r.stable && r.cg).map((r) => r.cg));
121
+ for (const r of raw) {
122
+ const usd = r.stable ? r.amount : (r.cg && prices[r.cg] != null ? r.amount * prices[r.cg] : undefined);
123
+ out.push({ symbol: r.symbol, amount: r.amount, ...(usd != null ? { usd } : {}) });
124
+ }
125
+ return out.sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0) || b.amount - a.amount);
126
+ }
127
+ // Friendly, provider-tagged labels for the activity log (mirrors franklin-run),
128
+ // so a finished step reads "Checking prediction markets · Predexon" instead of
129
+ // the raw tool name. Unknown tools fall back to their own name.
130
+ const TOOL_LABELS = {
131
+ web_search: 'Searching the web · Exa',
132
+ search_prediction_markets: 'Checking prediction markets · Predexon',
133
+ get_market_price: 'Fetching live price',
134
+ generate_music: 'Composing music',
135
+ make_phone_call: 'Placing phone call',
136
+ };
137
+ function labelFor(name) {
138
+ return TOOL_LABELS[name] ?? name;
139
+ }
140
+ // Model list grouping — by provider (company), like OpenRouter/Together. The
141
+ // provider is the id's vendor prefix (e.g. "anthropic/claude-…"); PROVIDER_ORDER
142
+ // puts the most-wanted vendors first, the rest fall in alphabetically.
143
+ const PROVIDER_LABEL = {
144
+ anthropic: 'Anthropic', openai: 'OpenAI', azure: 'OpenAI', google: 'Google', 'google-vertex': 'Google',
145
+ xai: 'xAI', deepseek: 'DeepSeek', meta: 'Meta', 'meta-llama': 'Meta', nvidia: 'NVIDIA',
146
+ moonshot: 'Moonshot', moonshotai: 'Moonshot', qwen: 'Qwen', alibaba: 'Qwen', mistral: 'Mistral',
147
+ mistralai: 'Mistral', minimax: 'MiniMax', zhipu: 'Zhipu', bytedance: 'ByteDance', cohere: 'Cohere',
148
+ perplexity: 'Perplexity', amazon: 'Amazon', microsoft: 'Microsoft', '01-ai': 'Yi', ai21: 'AI21',
149
+ };
150
+ const PROVIDER_ORDER = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'DeepSeek', 'Qwen', 'Moonshot', 'Meta', 'Mistral', 'MiniMax', 'NVIDIA'];
151
+ function providerLabel(id, ownedBy) {
152
+ const p = (id.split('/')[0] || ownedBy || '').toLowerCase();
153
+ return PROVIDER_LABEL[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Other');
154
+ }
155
+ // ─── Browser-attack surface gate ────────────────────────────────────────────
156
+ // Loopback binding alone is NOT an auth boundary: any web page the user has
157
+ // open can reach 127.0.0.1 (the browser attaches an Origin header but happily
158
+ // completes the request — WS handshakes aren't blocked by CORS, and a wallet-
159
+ // bearing agent in trust mode must not be drivable by a drive-by page).
160
+ //
161
+ // Policy: requests WITHOUT an Origin header are local processes (Electron main,
162
+ // curl, native clients — browsers can't strip Origin) → allowed. Browser
163
+ // origins are allowed only for Electron renderers (file:// / app://), local
164
+ // UIs (localhost / 127.0.0.1), the hosted web UI, and anything listed in
165
+ // FRANKLIN_SERVE_ALLOWED_ORIGINS (comma-separated). The literal "null" origin
166
+ // is REJECTED by default — sandboxed iframes on hostile pages also serialize
167
+ // to "null" — set FRANKLIN_SERVE_ALLOW_NULL_ORIGIN=1 if a renderer needs it.
168
+ // Defense-in-depth: when FRANKLIN_SERVE_TOKEN is set, every WS upgrade and
169
+ // /file request must also carry it (?token=…).
170
+ const DEFAULT_ALLOWED_ORIGINS = ['https://franklin.run'];
171
+ function isOriginAllowed(origin) {
172
+ if (!origin)
173
+ return true; // non-browser local client
174
+ if (origin === 'null')
175
+ return process.env.FRANKLIN_SERVE_ALLOW_NULL_ORIGIN === '1';
176
+ if (origin.startsWith('file://') || origin.startsWith('app://'))
177
+ return true; // Electron renderer
178
+ let host = '';
179
+ try {
180
+ host = new URL(origin).hostname;
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ if (host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1')
186
+ return true;
187
+ const extra = (process.env.FRANKLIN_SERVE_ALLOWED_ORIGINS || '')
188
+ .split(',').map((s) => s.trim()).filter(Boolean);
189
+ return [...DEFAULT_ALLOWED_ORIGINS, ...extra].includes(origin);
190
+ }
191
+ function tokenOk(url) {
192
+ const required = process.env.FRANKLIN_SERVE_TOKEN;
193
+ if (!required)
194
+ return true;
195
+ return url.searchParams.get('token') === required;
196
+ }
197
+ function send(ws, id, kind, payload) {
198
+ if (ws.readyState !== WebSocket.OPEN)
199
+ return;
200
+ ws.send(JSON.stringify({ id, kind, payload }));
201
+ }
202
+ // Flatten a stored Dialogue into the {role, content, kind:'text'} shape the UI
203
+ // renders. Tool calls / images are dropped here (the live stream carries those
204
+ // for the active turn); history replay just needs the text.
205
+ function dialogueText(content) {
206
+ if (typeof content === 'string')
207
+ return content;
208
+ const parts = content;
209
+ return parts
210
+ .map((p) => (p && typeof p === 'object' && 'type' in p && p.type === 'text' ? p.text : ''))
211
+ .filter(Boolean)
212
+ .join('');
213
+ }
214
+ export async function startServer(opts) {
215
+ const { port, workDir, debug } = opts;
216
+ const chain = loadChain();
217
+ const apiUrl = API_URLS[chain];
218
+ const userConfig = loadConfig();
219
+ // ── Single long-lived agent session ──
220
+ // interactiveSession owns the loop; we feed it user turns via a queue and
221
+ // fan its StreamEvents out to the connected socket.
222
+ let sessionStarted = false;
223
+ let currentModel = null;
224
+ // Live config ref + the cost-saver (research-bloat compaction) toggle. The UI
225
+ // flips this; we mutate the running config so the loop picks it up next turn.
226
+ let agentConfig = null;
227
+ let costSaver = userConfig['cost-saver'] !== 'false';
228
+ let inputQueue = [];
229
+ let inputResolver = null;
230
+ let abortFn = null;
231
+ // The socket + correlation id for the in-flight turn (single-window).
232
+ let activeWs = null;
233
+ let activeTurnId = null;
234
+ // We sometimes inject helper commands (`/model …`, `/clear`) as their own
235
+ // turns ahead of the real prompt. Each ends with its own turn_done — which
236
+ // would emit agent.done and clear activeTurnId, killing the real prompt's
237
+ // stream. This counter swallows each injected turn's events (text + turn_done)
238
+ // so the real prompt streams next under the same activeTurnId. It's a counter,
239
+ // not a bool, because a single send can inject more than one command.
240
+ let suppressTurns = 0;
241
+ // The client conversation id the running agent history belongs to. When a turn
242
+ // arrives for a different conversation we /clear the history so separate
243
+ // sidebar chats don't bleed context (the server runs one long-lived session).
244
+ let currentConvId = null;
245
+ const stepIds = new Map();
246
+ const stepLabels = new Map();
247
+ const stepDetails = new Map();
248
+ let stepSeq = 0;
249
+ function getUserInput() {
250
+ return new Promise((resolve) => {
251
+ if (inputQueue.length > 0) {
252
+ resolve(inputQueue.shift());
253
+ return;
254
+ }
255
+ inputResolver = resolve;
256
+ });
257
+ }
258
+ function pushInput(text) {
259
+ if (inputResolver) {
260
+ const r = inputResolver;
261
+ inputResolver = null;
262
+ r(text);
263
+ }
264
+ else {
265
+ inputQueue.push(text);
266
+ }
267
+ }
268
+ function emit(kind, payload) {
269
+ if (activeWs && activeTurnId)
270
+ send(activeWs, activeTurnId, kind, payload);
271
+ }
272
+ // ── Wallet balance (cached client) + post-turn broadcast ──
273
+ let walletClient = null;
274
+ async function getWallet() {
275
+ if (walletClient)
276
+ return walletClient;
277
+ const c = chain === 'solana'
278
+ ? await setupAgentSolanaWallet({ silent: true })
279
+ : setupAgentWallet({ silent: true });
280
+ walletClient = c;
281
+ return c;
282
+ }
283
+ async function fetchBalanceUsd() {
284
+ try {
285
+ const client = await getWallet();
286
+ return await retryFetchBalance(() => client.getBalance());
287
+ }
288
+ catch {
289
+ return undefined;
290
+ }
291
+ }
292
+ // After each turn, push the fresh balance to the UI (settlement may have
293
+ // changed it) so the sidebar pill + wallet page update live and stay in sync.
294
+ // Broadcast with a non-turn id so it reaches the client's global listeners.
295
+ function broadcastWalletAfterTurn() {
296
+ const ws = activeWs;
297
+ if (!ws)
298
+ return;
299
+ void fetchBalanceUsd().then((balanceUsd) => {
300
+ if (balanceUsd != null)
301
+ send(ws, 'wallet', 'wallet.event', { balanceUsd });
302
+ });
303
+ }
304
+ function onEvent(event) {
305
+ // Injected helper turn (/model, /clear): drop its output and end-of-turn so
306
+ // it neither shows in the chat nor closes the real prompt's stream.
307
+ if (suppressTurns > 0) {
308
+ if (event.kind === 'turn_done')
309
+ suppressTurns--;
310
+ return;
311
+ }
312
+ switch (event.kind) {
313
+ case 'text_delta':
314
+ // Drop internal compaction status lines (🗜 …) — they're CLI ops noise,
315
+ // not part of the answer, and shouldn't render in the desktop chat.
316
+ if (/^\s*\*?🗜/.test(event.text))
317
+ break;
318
+ emit('agent.text', { sessionId: '', text: event.text });
319
+ break;
320
+ case 'capability_start': {
321
+ let sid = stepIds.get(event.id);
322
+ if (sid == null) {
323
+ sid = ++stepSeq;
324
+ stepIds.set(event.id, sid);
325
+ }
326
+ const label = labelFor(event.name);
327
+ stepLabels.set(event.id, label);
328
+ // The per-call detail (the tool's key argument — query, prompt, symbol…)
329
+ // shown as small text next to the tool so you see WHAT it's doing.
330
+ const detail = event.preview?.trim() || '';
331
+ if (detail)
332
+ stepDetails.set(event.id, detail);
333
+ emit('agent.step', { sessionId: '', stepId: sid, label, detail, state: 'run' });
334
+ break;
335
+ }
336
+ case 'capability_done': {
337
+ const sid = stepIds.get(event.id) ?? ++stepSeq;
338
+ // Keep the original label on completion — sending '' here is what made
339
+ // finished steps render as a bare checkmark with no text.
340
+ emit('agent.step', { sessionId: '', stepId: sid, label: stepLabels.get(event.id) ?? '', detail: stepDetails.get(event.id) ?? '', state: 'done' });
341
+ const images = event.result?.images;
342
+ if (images && images.length) {
343
+ emit('agent.tool_result', {
344
+ sessionId: '',
345
+ toolCallId: event.id,
346
+ preview: event.result.output ?? '',
347
+ isError: event.result.isError,
348
+ artifacts: images.map((im) => ({
349
+ path: `data:${im.mediaType};base64,${im.base64}`,
350
+ mediaType: im.mediaType,
351
+ })),
352
+ });
353
+ }
354
+ // MusicGen / media tools save a local file and report its path in the
355
+ // output text. Surface generated audio (and stand-alone video/image
356
+ // files) as a playable artifact served over the /file route.
357
+ const out = event.result?.output ?? '';
358
+ const fileMatch = out.match(/(\/[^\s'"]*\.(?:mp3|wav|m4a|ogg|flac|mp4|webm))/i);
359
+ if (fileMatch) {
360
+ const filePath = fileMatch[1];
361
+ const ext = filePath.toLowerCase().split('.').pop() || '';
362
+ const mediaType = ext === 'mp4' || ext === 'webm' ? `video/${ext}` :
363
+ ext === 'mp3' ? 'audio/mpeg' :
364
+ ext === 'm4a' ? 'audio/mp4' : `audio/${ext}`;
365
+ emit('agent.tool_result', {
366
+ sessionId: '',
367
+ toolCallId: event.id,
368
+ preview: '',
369
+ artifacts: [{ path: `http://127.0.0.1:${port}/file?path=${encodeURIComponent(filePath)}`, mediaType }],
370
+ });
371
+ }
372
+ break;
373
+ }
374
+ case 'turn_done':
375
+ if (event.reason === 'completed') {
376
+ emit('agent.done', { sessionId: '', costUsd: 0 });
377
+ }
378
+ else if (event.error) {
379
+ emit('agent.error', { sessionId: '', message: event.error });
380
+ }
381
+ else {
382
+ emit('agent.done', { sessionId: '', costUsd: 0 });
383
+ }
384
+ activeTurnId = null;
385
+ stepIds.clear();
386
+ stepLabels.clear();
387
+ stepDetails.clear();
388
+ broadcastWalletAfterTurn();
389
+ break;
390
+ // thinking_delta / capability_input_delta / capability_progress / usage:
391
+ // not surfaced to the UI yet.
392
+ default:
393
+ break;
394
+ }
395
+ }
396
+ async function ensureSession(model) {
397
+ if (sessionStarted)
398
+ return;
399
+ sessionStarted = true;
400
+ currentModel = model;
401
+ const systemInstructions = assembleInstructions(workDir, model);
402
+ const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities, model);
403
+ try {
404
+ const { registerMoAConfig } = await import('../tools/moa.js');
405
+ registerMoAConfig(apiUrl, chain, model);
406
+ }
407
+ catch { /* MoA optional */ }
408
+ const capabilities = [...allCapabilities, subAgent];
409
+ const config = {
410
+ model,
411
+ apiUrl,
412
+ chain,
413
+ systemInstructions,
414
+ capabilities,
415
+ maxTurns: 100,
416
+ workingDir: workDir,
417
+ permissionMode: 'trust', // the desktop UI has no permission prompt yet
418
+ debug: !!debug,
419
+ showPrefetchStatus: false,
420
+ costSaver,
421
+ };
422
+ agentConfig = config;
423
+ interactiveSession(config, getUserInput, onEvent, (abort) => { abortFn = abort; })
424
+ .catch((err) => {
425
+ if (activeWs && activeTurnId) {
426
+ send(activeWs, activeTurnId, 'agent.error', { sessionId: '', message: err instanceof Error ? err.message : String(err) });
427
+ }
428
+ })
429
+ .finally(() => { sessionStarted = false; abortFn = null; });
430
+ }
431
+ // ── RPC handlers ──
432
+ async function handle(ws, msg) {
433
+ const { id, kind, payload } = msg;
434
+ const p = (payload ?? {});
435
+ switch (kind) {
436
+ case 'session.list': {
437
+ const metas = listSessions();
438
+ send(ws, id, 'response', {
439
+ sessions: metas.map((m) => ({
440
+ id: m.id,
441
+ title: `${m.model} · ${m.id.slice(0, 6)}`,
442
+ createdAt: m.createdAt,
443
+ updatedAt: m.updatedAt,
444
+ messageCount: m.messageCount ?? 0,
445
+ lastModel: m.model,
446
+ })),
447
+ });
448
+ break;
449
+ }
450
+ case 'session.load': {
451
+ const history = loadSessionHistory(String(p.id ?? ''));
452
+ const messages = history
453
+ .filter((d) => d.role === 'user' || d.role === 'assistant')
454
+ .map((d) => ({ role: d.role, content: dialogueText(d.content), kind: 'text' }))
455
+ .filter((m) => m.content);
456
+ send(ws, id, 'response', { messages });
457
+ break;
458
+ }
459
+ case 'wallet.info': {
460
+ try {
461
+ const client = await getWallet();
462
+ const address = client.getWalletAddress();
463
+ const balanceUsd = await fetchBalanceUsd(); // best-effort; undefined on failure
464
+ send(ws, id, 'response', { address, chain, balanceUsd });
465
+ }
466
+ catch (err) {
467
+ send(ws, id, 'error', { message: err instanceof Error ? err.message : 'wallet error' });
468
+ }
469
+ break;
470
+ }
471
+ case 'wallet.tokens': {
472
+ // Holdings: native ETH + curated Base ERC-20s with a non-zero balance.
473
+ // Public RPC can't enumerate ALL tokens, so this is a known-token sweep.
474
+ try {
475
+ if (chain !== 'base') {
476
+ send(ws, id, 'response', { tokens: [] });
477
+ break;
478
+ }
479
+ const client = await getWallet();
480
+ const address = await client.getWalletAddress();
481
+ const tokens = await listBaseHoldings(address);
482
+ send(ws, id, 'response', { tokens });
483
+ }
484
+ catch (err) {
485
+ send(ws, id, 'error', { message: err instanceof Error ? err.message : 'tokens error' });
486
+ }
487
+ break;
488
+ }
489
+ case 'history.load': {
490
+ // History is wallet-synced to the cloud (franklin.run, same as the web)
491
+ // with a local file (~/.blockrun) as cache + offline fallback. Cloud is
492
+ // the source of truth when reachable; otherwise we serve the local file.
493
+ const file = path.join(BLOCKRUN_DIR, 'franklin-desktop-history.json');
494
+ const readLocal = () => {
495
+ try {
496
+ if (fs.existsSync(file)) {
497
+ const p2 = JSON.parse(fs.readFileSync(file, 'utf-8'));
498
+ if (Array.isArray(p2))
499
+ return p2;
500
+ }
501
+ }
502
+ catch { /* ignore */ }
503
+ return [];
504
+ };
505
+ const writeLocal = (c) => {
506
+ try {
507
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
508
+ fs.writeFileSync(file, JSON.stringify(c), { mode: 0o600 });
509
+ }
510
+ catch { /* ignore */ }
511
+ };
512
+ try {
513
+ const local = readLocal();
514
+ if (isCloudSyncEnabled()) {
515
+ try {
516
+ const cloud = await cloudList();
517
+ if (cloud.length > 0) {
518
+ writeLocal(cloud); // refresh local cache
519
+ send(ws, id, 'response', { conversations: cloud });
520
+ break;
521
+ }
522
+ // Cloud empty but we have local history → migrate it up.
523
+ if (local.length > 0)
524
+ void cloudSync(local).catch(() => { });
525
+ }
526
+ catch { /* offline / not-deployed → fall back to local */ }
527
+ }
528
+ send(ws, id, 'response', { conversations: local });
529
+ }
530
+ catch (err) {
531
+ send(ws, id, 'error', { message: err instanceof Error ? err.message : 'history load error' });
532
+ }
533
+ break;
534
+ }
535
+ case 'history.save': {
536
+ try {
537
+ const conversations = Array.isArray(p.conversations) ? p.conversations : [];
538
+ // Local file = instant durable cache; cloud = best-effort wallet sync.
539
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
540
+ fs.writeFileSync(path.join(BLOCKRUN_DIR, 'franklin-desktop-history.json'), JSON.stringify(conversations), { mode: 0o600 });
541
+ if (isCloudSyncEnabled())
542
+ void cloudSync(conversations).catch(() => { });
543
+ send(ws, id, 'response', { ok: true });
544
+ }
545
+ catch (err) {
546
+ send(ws, id, 'error', { message: err instanceof Error ? err.message : 'history save error' });
547
+ }
548
+ break;
549
+ }
550
+ case 'wallet.swaps': {
551
+ try {
552
+ send(ws, id, 'response', { swaps: readSwaps(100) });
553
+ }
554
+ catch (err) {
555
+ send(ws, id, 'error', { message: err instanceof Error ? err.message : 'swaps error' });
556
+ }
557
+ break;
558
+ }
559
+ case 'wallet.spend': {
560
+ // Real spend, sourced from the x402 settlement ledger (cost_log.jsonl) —
561
+ // the same truth the CLI dashboard uses. Covers BOTH model calls and
562
+ // paid tools (web search, image gen, …), not a token estimate.
563
+ try {
564
+ const rows = loadSdkSettlements();
565
+ const byModel = {};
566
+ let totalUsd = 0;
567
+ for (const r of rows) {
568
+ const key = r.model || r.endpoint || 'unknown';
569
+ const b = byModel[key] ?? { usd: 0, count: 0 };
570
+ b.usd += r.costUsd;
571
+ b.count += 1;
572
+ byModel[key] = b;
573
+ totalUsd += r.costUsd;
574
+ }
575
+ const receipts = [...rows]
576
+ .sort((a, b) => b.ts - a.ts)
577
+ .slice(0, 100)
578
+ .map((r) => ({ ts: r.ts, model: r.model || r.endpoint || 'unknown', usd: r.costUsd }));
579
+ send(ws, id, 'response', { totalUsd, requests: rows.length, byModel, receipts });
580
+ }
581
+ catch (err) {
582
+ send(ws, id, 'error', { message: err instanceof Error ? err.message : 'spend error' });
583
+ }
584
+ break;
585
+ }
586
+ case 'models.list': {
587
+ try {
588
+ const models = await getModelsByCategory('chat');
589
+ const mapped = models.map((m) => {
590
+ const provider = providerLabel(m.id, m.owned_by);
591
+ return {
592
+ id: m.id,
593
+ label: m.name,
594
+ free: m.billing_mode === 'free',
595
+ group: provider,
596
+ provider,
597
+ contextWindow: m.context_window,
598
+ };
599
+ });
600
+ // Group by provider (PROVIDER_ORDER first, then alphabetical); free
601
+ // models surface first within each provider. The picker renders
602
+ // consecutive same-`group` items as one section, so sort accordingly.
603
+ const rank = (g) => { const i = PROVIDER_ORDER.indexOf(g); return i < 0 ? 999 : i; };
604
+ mapped.sort((a, b) => rank(a.group) - rank(b.group) ||
605
+ a.group.localeCompare(b.group) ||
606
+ (a.free === b.free ? 0 : a.free ? -1 : 1) ||
607
+ a.label.localeCompare(b.label));
608
+ send(ws, id, 'response', { models: mapped });
609
+ }
610
+ catch (err) {
611
+ send(ws, id, 'error', { message: err instanceof Error ? err.message : 'models error' });
612
+ }
613
+ break;
614
+ }
615
+ case 'agent.send': {
616
+ const text = String(p.text ?? '').trim();
617
+ if (!text) {
618
+ send(ws, id, 'agent.error', { sessionId: '', message: 'empty input' });
619
+ break;
620
+ }
621
+ activeWs = ws;
622
+ activeTurnId = id;
623
+ stepIds.clear();
624
+ // A non-empty model means "switch the chat model". Media turns send no
625
+ // model (the image/video model is a TOOL parameter baked into the
626
+ // prompt, NOT the chat model — switching the chat model to an image
627
+ // model breaks the turn), so we keep the current chat model for them.
628
+ const desiredModel = p.model ? String(p.model) : null;
629
+ const clientConvId = p.convId ? String(p.convId) : null;
630
+ await ensureSession(desiredModel || userConfig['default-model'] || FREE_DEFAULT_MODEL);
631
+ // Conversation switch → wipe the agent's history so a new/other chat
632
+ // doesn't inherit the previous one's context. Only when we already had a
633
+ // different conversation loaded (not on the very first turn).
634
+ if (clientConvId && currentConvId && clientConvId !== currentConvId) {
635
+ suppressTurns++; // swallow the injected /clear turn (see onEvent)
636
+ pushInput('/clear');
637
+ }
638
+ if (clientConvId)
639
+ currentConvId = clientConvId;
640
+ if (desiredModel && currentModel && desiredModel !== currentModel) {
641
+ suppressTurns++; // swallow the injected /model turn (see onEvent)
642
+ pushInput(`/model ${desiredModel}`);
643
+ currentModel = desiredModel;
644
+ }
645
+ pushInput(text);
646
+ break;
647
+ }
648
+ case 'settings.get':
649
+ send(ws, id, 'response', { costSaver });
650
+ break;
651
+ case 'settings.set': {
652
+ if (typeof p.costSaver === 'boolean') {
653
+ costSaver = p.costSaver;
654
+ if (agentConfig)
655
+ agentConfig.costSaver = costSaver; // live-update the running session
656
+ setConfigValue('cost-saver', costSaver ? 'true' : 'false'); // persist across restarts
657
+ }
658
+ send(ws, id, 'response', { costSaver });
659
+ break;
660
+ }
661
+ case 'agent.cancel':
662
+ if (abortFn)
663
+ abortFn();
664
+ break;
665
+ case 'agent.permissionResponse':
666
+ // permissionMode is 'trust' — nothing to unblock.
667
+ break;
668
+ default:
669
+ send(ws, id, 'error', { message: `Unknown kind: ${kind}` });
670
+ }
671
+ }
672
+ // ── HTTP + WS ──
673
+ // HTTP: a /file route streams a generated media file (audio/video/image) so
674
+ // the renderer can play it. The path param is confined to media files under
675
+ // the session work dir (plus FRANKLIN_SERVE_FILE_ROOTS extras) — NOT the
676
+ // whole filesystem — and the request must pass the Origin gate. Otherwise a
677
+ // hostile page could fetch wallet/key files off the loopback port.
678
+ const fileRoots = [
679
+ workDir,
680
+ path.join(BLOCKRUN_DIR, 'content'),
681
+ ...(process.env.FRANKLIN_SERVE_FILE_ROOTS || '').split(',').map((s) => s.trim()).filter(Boolean),
682
+ ].map((r) => { try {
683
+ return fs.realpathSync(r);
684
+ }
685
+ catch {
686
+ return null;
687
+ } }).filter((r) => !!r);
688
+ const MEDIA_MIME = {
689
+ mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', flac: 'audio/flac',
690
+ mp4: 'video/mp4', webm: 'video/webm',
691
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
692
+ };
693
+ const httpServer = http.createServer((req, res) => {
694
+ try {
695
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
696
+ const origin = req.headers.origin;
697
+ if (url.pathname === '/file') {
698
+ if (!isOriginAllowed(origin) || !tokenOk(url)) {
699
+ res.writeHead(403);
700
+ res.end();
701
+ return;
702
+ }
703
+ const p = url.searchParams.get('path') || '';
704
+ if (!p || !fs.existsSync(p) || !fs.statSync(p).isFile()) {
705
+ res.writeHead(404);
706
+ res.end();
707
+ return;
708
+ }
709
+ // Resolve symlinks before the prefix check so a link can't escape a root.
710
+ const real = fs.realpathSync(p);
711
+ const inRoot = fileRoots.some((root) => real === root || real.startsWith(root + path.sep));
712
+ const ext = real.toLowerCase().split('.').pop() || '';
713
+ const mime = MEDIA_MIME[ext];
714
+ if (!inRoot || !mime) {
715
+ res.writeHead(403);
716
+ res.end();
717
+ return;
718
+ }
719
+ res.writeHead(200, {
720
+ 'Content-Type': mime,
721
+ // Reflect the (already vetted) origin instead of `*` so arbitrary
722
+ // sites can't read the bytes cross-origin.
723
+ ...(origin && origin !== 'null' ? { 'Access-Control-Allow-Origin': origin } : {}),
724
+ });
725
+ fs.createReadStream(real).pipe(res);
726
+ return;
727
+ }
728
+ }
729
+ catch { /* fall through to 404 */ }
730
+ res.writeHead(404);
731
+ res.end();
732
+ });
733
+ const wss = new WebSocket.Server({
734
+ server: httpServer,
735
+ path: '/agent',
736
+ // Same gate as /file: refuse upgrades from non-allowlisted browser origins
737
+ // (and require the token when one is configured). See isOriginAllowed.
738
+ verifyClient: (info) => {
739
+ const url = new URL(info.req.url || '/', 'http://127.0.0.1');
740
+ const allowed = isOriginAllowed(info.origin || info.req.headers.origin) && tokenOk(url);
741
+ if (!allowed && debug)
742
+ console.log(`[serve] rejected WS upgrade from origin=${info.origin ?? 'n/a'}`);
743
+ return allowed;
744
+ },
745
+ });
746
+ wss.on('connection', (ws) => {
747
+ ws.on('message', (raw) => {
748
+ let msg;
749
+ try {
750
+ msg = JSON.parse(raw.toString());
751
+ }
752
+ catch {
753
+ return;
754
+ }
755
+ handle(ws, msg).catch((err) => {
756
+ send(ws, msg.id, 'error', { message: err instanceof Error ? err.message : String(err) });
757
+ });
758
+ });
759
+ });
760
+ await new Promise((resolve) => {
761
+ httpServer.listen(port, '127.0.0.1', () => {
762
+ // eslint-disable-next-line no-console
763
+ console.log(`Franklin agent server on ws://127.0.0.1:${port}/agent (chain: ${chain}, workdir: ${workDir})`);
764
+ resolve();
765
+ });
766
+ });
767
+ }