@blockrun/franklin 3.26.1 → 3.27.1

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.
@@ -15,11 +15,31 @@
15
15
  * Works behind NAT and through laptop sleep/wake without needing a public
16
16
  * HTTPS endpoint. `node fetch` is the only HTTP dep.
17
17
  */
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
18
21
  import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm';
19
22
  import { interactiveSession } from '../agent/loop.js';
20
23
  import { ModelClient } from '../agent/llm.js';
21
24
  import { extractBrainEntities } from '../brain/extract.js';
22
25
  import { extractLearnings } from '../learnings/extractor.js';
26
+ // Per-bot prefs (persisted so a restart keeps the user's choice).
27
+ const PREFS_FILE = path.join(os.homedir(), '.blockrun', 'telegram-prefs.json');
28
+ function loadPrefs() {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(PREFS_FILE, 'utf-8'));
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ function savePrefs(prefs) {
37
+ try {
38
+ fs.mkdirSync(path.dirname(PREFS_FILE), { recursive: true });
39
+ fs.writeFileSync(PREFS_FILE, JSON.stringify(prefs, null, 2), { mode: 0o600 });
40
+ }
41
+ catch { /* best-effort */ }
42
+ }
23
43
  const TG_API = 'https://api.telegram.org';
24
44
  const POLL_TIMEOUT_SECONDS = 25;
25
45
  // Telegram caps messages at 4096 chars; keep a margin so our chunk headers
@@ -93,6 +113,10 @@ export async function runTelegramBot(agentConfig, opts) {
93
113
  running: true,
94
114
  restartRequested: false,
95
115
  stoppedBy: undefined,
116
+ // Tool names used in the current turn → one summary at turn end (not one
117
+ // message per call). `showTools` gates whether that summary is sent.
118
+ toolsUsed: [],
119
+ showTools: loadPrefs().showTools ?? true,
96
120
  };
97
121
  // ── Telegram HTTP helpers ────────────────────────────────────────────
98
122
  const api = async (method, body) => {
@@ -135,11 +159,20 @@ export async function runTelegramBot(agentConfig, opts) {
135
159
  // ── Slash commands (handled by the bot, not the agent) ──────────────
136
160
  const handleSlashCommand = async (chatId, text) => {
137
161
  const cmd = text.trim().toLowerCase();
162
+ // `/tools` toggles the per-turn tool summary (takes on/off, or bare = flip).
163
+ if (cmd === '/tools' || cmd.startsWith('/tools ')) {
164
+ const arg = cmd.slice('/tools'.length).trim();
165
+ state.showTools = arg === 'on' ? true : arg === 'off' ? false : !state.showTools;
166
+ savePrefs({ showTools: state.showTools });
167
+ await sendMessage(chatId, `🔧 Tool summary: ${state.showTools ? 'on ✅' : 'off'}`);
168
+ return true;
169
+ }
138
170
  switch (cmd) {
139
171
  case '/start':
140
172
  case '/help':
141
173
  await sendMessage(chatId, 'Franklin bot\n\n' +
142
174
  '/new — start a fresh conversation (clears history)\n' +
175
+ '/tools [on|off] — toggle the per-turn tool-usage summary\n' +
143
176
  '/balance — show wallet USDC balance\n' +
144
177
  '/status — show chain, model, and session stats\n' +
145
178
  '/help — this message\n\n' +
@@ -149,6 +182,9 @@ export async function runTelegramBot(agentConfig, opts) {
149
182
  state.restartRequested = true;
150
183
  // Drain any pending input and wake the session so it unwinds.
151
184
  state.inputQueue.length = 0;
185
+ // Drop tools recorded by a turn this reset interrupts, so they don't
186
+ // leak into the new conversation's first summary.
187
+ state.toolsUsed = [];
152
188
  {
153
189
  const waiters = state.inputWaiters.splice(0);
154
190
  for (const w of waiters)
@@ -226,16 +262,16 @@ export async function runTelegramBot(agentConfig, opts) {
226
262
  }
227
263
  break;
228
264
  case 'capability_start':
229
- // Best-effort signal that the agent is working. Flush any buffered
230
- // text first so the user sees the narrative order correctly.
231
- if (state.currentChatId !== undefined) {
232
- if (state.responseBuffer.trim()) {
233
- const chatId = state.currentChatId;
234
- const text = state.responseBuffer.trim();
235
- state.responseBuffer = '';
236
- void sendMessage(chatId, text);
237
- }
238
- void sendMessage(state.currentChatId, `⏳ ${event.name}…`);
265
+ // Record the tool (for the turn-end summary) and flush buffered text so
266
+ // narrative order reads right. No per-tool message a multi-tool run
267
+ // otherwise floods the chat.
268
+ if (event.name)
269
+ state.toolsUsed.push(event.name);
270
+ if (state.currentChatId !== undefined && state.responseBuffer.trim()) {
271
+ const chatId = state.currentChatId;
272
+ const text = state.responseBuffer.trim();
273
+ state.responseBuffer = '';
274
+ void sendMessage(chatId, text);
239
275
  }
240
276
  break;
241
277
  case 'turn_done': {
@@ -244,6 +280,12 @@ export async function runTelegramBot(agentConfig, opts) {
244
280
  state.responseBuffer = '';
245
281
  if (chatId !== undefined && text)
246
282
  void sendChunked(chatId, text);
283
+ // One tool summary per turn (toggle with /tools).
284
+ if (chatId !== undefined && state.showTools && state.toolsUsed.length) {
285
+ const uniq = [...new Set(state.toolsUsed)];
286
+ void sendMessage(chatId, `🔧 Used ${state.toolsUsed.length} tool${state.toolsUsed.length === 1 ? '' : 's'}: ${uniq.join(' · ')}`);
287
+ }
288
+ state.toolsUsed = [];
247
289
  if (event.reason === 'error' && event.error && chatId !== undefined) {
248
290
  void sendMessage(chatId, `❌ Error: ${event.error}`);
249
291
  }
@@ -252,10 +294,18 @@ export async function runTelegramBot(agentConfig, opts) {
252
294
  }
253
295
  };
254
296
  // ── Long-poll loop (runs concurrently with interactiveSession) ──────
297
+ // Captured from getMe so the group @mention gate knows the bot's handle/id.
298
+ let botUsername;
299
+ let botId;
255
300
  const pollLoop = async () => {
256
301
  try {
257
302
  const me = await api('getMe', {});
258
- log(`[telegram] connected as @${me.username ?? '(unknown)'} — owner=${opts.ownerId}`);
303
+ botUsername = me.username;
304
+ botId = me.id;
305
+ log(`[telegram] connected as @${me.username ?? '(unknown)'} — owner=${opts.ownerId}` +
306
+ (opts.allowedUsers && opts.allowedUsers.size
307
+ ? ` + ${opts.allowedUsers.size} allowed user(s)`
308
+ : ''));
259
309
  }
260
310
  catch (err) {
261
311
  state.stoppedBy = err;
@@ -283,23 +333,42 @@ export async function runTelegramBot(agentConfig, opts) {
283
333
  const msg = u.message;
284
334
  if (!msg?.text || !msg.from)
285
335
  continue;
286
- if (msg.from.id !== opts.ownerId) {
336
+ // In groups, only act when the bot is addressed: @mentioned (incl. the
337
+ // `/cmd@bot` form) or replied to. Everything else — plain chatter AND
338
+ // bare slash commands — is ignored SILENTLY. Private chats need no mention.
339
+ const isGroup = !!msg.chat.type && msg.chat.type !== 'private';
340
+ let text = msg.text;
341
+ if (isGroup) {
342
+ const tag = botUsername ? `@${botUsername}` : '';
343
+ const mentioned = !!tag && text.toLowerCase().includes(tag.toLowerCase());
344
+ const repliedToBot = !!botId && msg.reply_to_message?.from?.id === botId;
345
+ if (!mentioned && !repliedToBot)
346
+ continue;
347
+ // Strip the @mention so the agent gets a clean prompt.
348
+ if (mentioned && tag) {
349
+ text = text.replace(new RegExp(tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig'), '').trim();
350
+ }
351
+ }
352
+ if (!text)
353
+ continue; // mention with no actual content
354
+ const isAuthorized = msg.from.id === opts.ownerId || !!opts.allowedUsers?.has(msg.from.id);
355
+ if (!isAuthorized) {
287
356
  void sendMessage(msg.chat.id, 'Not authorized.');
288
357
  log(`[telegram] rejected unauthorized sender id=${msg.from.id} ` +
289
358
  `username=@${msg.from.username ?? 'n/a'}`);
290
359
  continue;
291
360
  }
292
- log(`[telegram] ← ${msg.text.slice(0, 80)}${msg.text.length > 80 ? '…' : ''}`);
361
+ log(`[telegram] ← ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}`);
293
362
  // Intercept bot slash commands before handing off to the agent.
294
- if (msg.text.trim().startsWith('/')) {
363
+ if (text.trim().startsWith('/')) {
295
364
  state.currentChatId = msg.chat.id;
296
- const handled = await handleSlashCommand(msg.chat.id, msg.text);
365
+ const handled = await handleSlashCommand(msg.chat.id, text);
297
366
  if (handled)
298
367
  continue;
299
368
  // Unknown slash command: fall through to agent (which has its own
300
369
  // slash handling for /retry, /model, /cost, …).
301
370
  }
302
- enqueueInput(msg.chat.id, msg.text);
371
+ enqueueInput(msg.chat.id, text);
303
372
  }
304
373
  }
305
374
  };
@@ -1,3 +1,5 @@
1
+ declare const VALID_KEYS: readonly ["default-model", "sonnet-model", "opus-model", "haiku-model", "smart-routing", "permission-mode", "max-turns", "auto-compact", "cost-saver", "session-save", "debug", "zerox-api-key", "base-rpc-url"];
2
+ type ConfigKey = (typeof VALID_KEYS)[number];
1
3
  export interface AppConfig {
2
4
  'default-model'?: string;
3
5
  'sonnet-model'?: string;
@@ -7,6 +9,8 @@ export interface AppConfig {
7
9
  'permission-mode'?: string;
8
10
  'max-turns'?: string;
9
11
  'auto-compact'?: string;
12
+ /** Research-bloat compaction toggle for the desktop ("false" disables). */
13
+ 'cost-saver'?: string;
10
14
  'session-save'?: string;
11
15
  'debug'?: string;
12
16
  /** 0x V2 Swap API key for Base swaps. Free at https://dashboard.0x.org. Each user supplies their own; the on-chain affiliate fee routes to BlockRun regardless. */
@@ -15,4 +19,7 @@ export interface AppConfig {
15
19
  'base-rpc-url'?: string;
16
20
  }
17
21
  export declare function loadConfig(): AppConfig;
22
+ /** Persist a single config key (used by the desktop server for live toggles). */
23
+ export declare function setConfigValue(key: ConfigKey, value: string): void;
18
24
  export declare function configCommand(action: string, keyOrUndefined?: string, value?: string): void;
25
+ export {};
@@ -13,6 +13,7 @@ const VALID_KEYS = [
13
13
  'permission-mode',
14
14
  'max-turns',
15
15
  'auto-compact',
16
+ 'cost-saver',
16
17
  'session-save',
17
18
  'debug',
18
19
  'zerox-api-key',
@@ -48,6 +49,12 @@ function saveConfig(config) {
48
49
  function isValidKey(key) {
49
50
  return VALID_KEYS.includes(key);
50
51
  }
52
+ /** Persist a single config key (used by the desktop server for live toggles). */
53
+ export function setConfigValue(key, value) {
54
+ const config = loadConfig();
55
+ config[key] = value;
56
+ saveConfig(config);
57
+ }
51
58
  export function configCommand(action, keyOrUndefined, value) {
52
59
  if (action === 'list') {
53
60
  const config = loadConfig();
@@ -0,0 +1,7 @@
1
+ interface ServeOptions {
2
+ port?: string;
3
+ workDir?: string;
4
+ debug?: boolean;
5
+ }
6
+ export declare function serveCommand(options: ServeOptions): Promise<void>;
7
+ export {};
@@ -0,0 +1,7 @@
1
+ import path from 'node:path';
2
+ import { startServer } from '../serve/server.js';
3
+ export async function serveCommand(options) {
4
+ const port = Number(options.port) || 3737;
5
+ const workDir = options.workDir ? path.resolve(options.workDir) : process.cwd();
6
+ await startServer({ port, workDir, debug: !!options.debug });
7
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `franklin slack` — start the Slack ingress bot.
3
+ *
4
+ * Designed to run on a server / always-on laptop. Reads the bot token, app
5
+ * token, and the user allowlist from env (or ~/.blockrun/config). Uses
6
+ * trust-mode permissions because the operator is remote — there's no terminal
7
+ * prompt they can answer per tool call. The `SLACK_ALLOWED_USERS` allowlist in
8
+ * `runSlackBot` is the real security boundary, mirroring Telegram's owner lock.
9
+ */
10
+ interface SlackCommandOptions {
11
+ model?: string;
12
+ debug?: boolean;
13
+ }
14
+ export declare function slackCommand(opts: SlackCommandOptions): Promise<void>;
15
+ export {};
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `franklin slack` — start the Slack ingress bot.
3
+ *
4
+ * Designed to run on a server / always-on laptop. Reads the bot token, app
5
+ * token, and the user allowlist from env (or ~/.blockrun/config). Uses
6
+ * trust-mode permissions because the operator is remote — there's no terminal
7
+ * prompt they can answer per tool call. The `SLACK_ALLOWED_USERS` allowlist in
8
+ * `runSlackBot` is the real security boundary, mirroring Telegram's owner lock.
9
+ */
10
+ import chalk from 'chalk';
11
+ import { loadChain, API_URLS } from '../config.js';
12
+ import { assembleInstructions } from '../agent/context.js';
13
+ import { allCapabilities } from '../tools/index.js';
14
+ import { loadMcpConfig } from '../mcp/config.js';
15
+ import { connectMcpServers, disconnectMcpServers } from '../mcp/client.js';
16
+ import { loadConfig } from './config.js';
17
+ import { runSlackBot } from '../channel/slack.js';
18
+ import { findLatestSessionByChannel } from '../session/storage.js';
19
+ export async function slackCommand(opts) {
20
+ const botToken = process.env.SLACK_BOT_TOKEN;
21
+ const appToken = process.env.SLACK_APP_TOKEN;
22
+ const allowedRaw = process.env.SLACK_ALLOWED_USERS;
23
+ if (!botToken || !appToken || !allowedRaw) {
24
+ console.error(chalk.red('Missing Slack config.'));
25
+ console.error(chalk.dim('\nSet three env vars before running `franklin slack`:\n' +
26
+ ' SLACK_BOT_TOKEN=<xoxb-… Bot User OAuth token>\n' +
27
+ ' SLACK_APP_TOKEN=<xapp-… app-level token with connections:write>\n' +
28
+ ' SLACK_ALLOWED_USERS=<comma-separated Slack user ids, e.g. U01ABC,U02DEF>\n\n' +
29
+ 'Socket Mode must be enabled on the app, and the bot must be invited to\n' +
30
+ 'the channel (/invite @your-bot). Find a user id via their profile →\n' +
31
+ '⋮ → Copy member ID.'));
32
+ process.exit(1);
33
+ }
34
+ const allowedUsers = new Set(allowedRaw.split(',').map((s) => s.trim()).filter(Boolean));
35
+ if (allowedUsers.size === 0) {
36
+ console.error(chalk.red('SLACK_ALLOWED_USERS is empty — that would deny everyone.'));
37
+ process.exit(1);
38
+ }
39
+ const chain = loadChain();
40
+ const apiUrl = API_URLS[chain];
41
+ const config = loadConfig();
42
+ const model = opts.model ||
43
+ config['default-model'] ||
44
+ 'nvidia/qwen3-coder-480b';
45
+ const workingDir = process.cwd();
46
+ const systemInstructions = assembleInstructions(workingDir, model);
47
+ // Connect MCP servers (Notion, etc.) so the bot exposes their tools — mirrors
48
+ // what `franklin start` does. Without this the bot only has built-in tools.
49
+ const mcpConfig = loadMcpConfig(workingDir);
50
+ let mcpTools = [];
51
+ const mcpServerCount = Object.keys(mcpConfig.mcpServers).filter((k) => !mcpConfig.mcpServers[k].disabled).length;
52
+ if (mcpServerCount > 0) {
53
+ try {
54
+ mcpTools = await connectMcpServers(mcpConfig, opts.debug);
55
+ if (mcpTools.length > 0) {
56
+ console.log(chalk.dim(` MCP: ${mcpTools.length} tools from ${mcpServerCount} server(s)`));
57
+ }
58
+ }
59
+ catch (err) {
60
+ console.error(chalk.yellow(` MCP error: ${err.message}`));
61
+ }
62
+ }
63
+ // Resume the most recent session tagged for THIS workspace bot so a process
64
+ // restart doesn't drop the conversation. MVP v1 keeps one shared session per
65
+ // bot (see channel/slack.ts), so the channel tag is workspace-scoped.
66
+ const channelTag = 'slack:shared';
67
+ const prior = findLatestSessionByChannel(channelTag);
68
+ if (prior) {
69
+ console.log(chalk.dim(` resuming session ${prior.id} (${prior.messageCount} msgs, ` +
70
+ `last update ${new Date(prior.updatedAt).toLocaleString()})`));
71
+ }
72
+ const agentConfig = {
73
+ model,
74
+ apiUrl,
75
+ chain,
76
+ systemInstructions,
77
+ capabilities: [...allCapabilities, ...mcpTools],
78
+ workingDir,
79
+ // No interactive terminal for permission prompts — remote operator can't
80
+ // answer y/n per tool. The Slack allowlist is the security boundary.
81
+ permissionMode: 'trust',
82
+ debug: opts.debug,
83
+ sessionChannel: channelTag,
84
+ resumeSessionId: prior?.id,
85
+ };
86
+ console.log(chalk.bold.cyan('Franklin Slack bot'));
87
+ console.log(chalk.dim(` chain: ${chain}`));
88
+ console.log(chalk.dim(` model: ${model}`));
89
+ console.log(chalk.dim(` allowed users: ${allowedUsers.size}`));
90
+ console.log(chalk.yellow(' permission mode: trust — every tool the model picks will execute ' +
91
+ 'without confirmation. The allowlist is your only gate.\n'));
92
+ let exitAttempts = 0;
93
+ process.on('SIGINT', () => {
94
+ exitAttempts++;
95
+ if (exitAttempts === 1) {
96
+ console.log(chalk.dim('\nStopping… (press Ctrl-C again to force)'));
97
+ }
98
+ else {
99
+ process.exit(130);
100
+ }
101
+ });
102
+ try {
103
+ await runSlackBot(agentConfig, {
104
+ botToken,
105
+ appToken,
106
+ allowedUsers,
107
+ debug: opts.debug,
108
+ log: (line) => console.log(chalk.dim(line)),
109
+ });
110
+ }
111
+ catch (err) {
112
+ console.error(chalk.red(`Slack bot failed: ${err.message}`));
113
+ process.exit(1);
114
+ }
115
+ finally {
116
+ disconnectMcpServers().catch(() => { });
117
+ }
118
+ }
@@ -30,6 +30,14 @@ export async function telegramCommand(opts) {
30
30
  console.error(chalk.red(`TELEGRAM_OWNER_ID must be a positive integer, got: ${ownerRaw}`));
31
31
  process.exit(1);
32
32
  }
33
+ // Optional allowlist: extra numeric user ids that may drive the bot (e.g. other
34
+ // people in a group). Comma-separated. Owner is always allowed.
35
+ const allowedUsers = new Set([ownerId]);
36
+ for (const raw of (process.env.TELEGRAM_ALLOWED_USERS ?? '').split(',')) {
37
+ const id = parseInt(raw.trim(), 10);
38
+ if (Number.isFinite(id) && id > 0)
39
+ allowedUsers.add(id);
40
+ }
33
41
  const chain = loadChain();
34
42
  const apiUrl = API_URLS[chain];
35
43
  const config = loadConfig();
@@ -85,6 +93,7 @@ export async function telegramCommand(opts) {
85
93
  await runTelegramBot(agentConfig, {
86
94
  token,
87
95
  ownerId,
96
+ allowedUsers,
88
97
  log: (line) => console.log(chalk.dim(line)),
89
98
  });
90
99
  }
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { configCommand } from './commands/config.js';
20
20
  import { statsCommand } from './commands/stats.js';
21
21
  import { logsCommand } from './commands/logs.js';
22
22
  import { daemonCommand } from './commands/daemon.js';
23
+ import { slackCommand } from './commands/slack.js';
23
24
  import { initCommand } from './commands/init.js';
24
25
  import { uninitCommand } from './commands/uninit.js';
25
26
  import { proxyCommand } from './commands/proxy.js';
@@ -79,6 +80,12 @@ program
79
80
  .description('Manage franklin background proxy (start|stop|status)')
80
81
  .option('-p, --port <port>', 'Proxy port', '8402')
81
82
  .action((action, options) => daemonCommand(action, options));
83
+ program
84
+ .command('slack')
85
+ .description('Run the Slack ingress bot (Socket Mode)')
86
+ .option('--model <model>', 'Model to use')
87
+ .option('--debug', 'Verbose Slack/Bolt logging')
88
+ .action((options) => slackCommand(options));
82
89
  program
83
90
  .command('panel')
84
91
  .description('Open the Franklin dashboard (localhost:3100)')
@@ -87,6 +94,16 @@ program
87
94
  const { panelCommand } = await import('./commands/panel.js');
88
95
  await panelCommand(options);
89
96
  });
97
+ program
98
+ .command('serve')
99
+ .description('Run the local agent server for the desktop app / browser UI (WebSocket on localhost:3737)')
100
+ .option('-p, --port <port>', 'Agent server port', '3737')
101
+ .option('--work-dir <dir>', 'Working directory for tools (default: cwd)')
102
+ .option('--debug', 'Verbose logging')
103
+ .action(async (options) => {
104
+ const { serveCommand } = await import('./commands/serve.js');
105
+ await serveCommand(options);
106
+ });
90
107
  program
91
108
  .command('models')
92
109
  .description('List available models and pricing')
@@ -325,5 +342,10 @@ else if (!firstArg || firstArg.startsWith('-')) {
325
342
  process.exit(process.exitCode ?? 0);
326
343
  }
327
344
  else {
328
- program.parse();
345
+ // Force node-style argv slicing. When the CLI is embedded and run via
346
+ // Electron-as-node (ELECTRON_RUN_AS_NODE=1, e.g. the desktop app spawning
347
+ // `franklin serve`), commander otherwise detects `process.versions.electron`
348
+ // + no defaultApp and slices argv as a packaged-electron app — treating the
349
+ // script path as the command. `from: 'node'` keeps [exec, script, ...args].
350
+ program.parse(process.argv, { from: 'node' });
329
351
  }
@@ -575,16 +575,19 @@ export function getFallbackChain(tier, profile = 'auto') {
575
575
  // models was being handed to qwen3-coder-480b — a coder model trying to
576
576
  // do technical analysis. Reported 2026-05-03 with a markets question
577
577
  // routed to a coder model on Sonnet failure.
578
+ // 2026-06-07: nvidia/glm-4.7 dropped from every chain — NVIDIA NIM hung, the
579
+ // gateway redirects it to qwen3-coder-480b (already present here), so routing to
580
+ // it just wasted a slot and mislabeled the model. qwen3-coder-480b + llama-4-
581
+ // maverick are both healthy and cover all categories.
578
582
  const FREE_MODELS_BY_CATEGORY = {
579
- coding: ['nvidia/qwen3-coder-480b', 'nvidia/glm-4.7', 'nvidia/llama-4-maverick'],
580
- trading: ['nvidia/glm-4.7', 'nvidia/llama-4-maverick', 'nvidia/qwen3-coder-480b'],
581
- research: ['nvidia/glm-4.7', 'nvidia/llama-4-maverick', 'nvidia/qwen3-coder-480b'],
582
- reasoning: ['nvidia/glm-4.7', 'nvidia/qwen3-coder-480b', 'nvidia/llama-4-maverick'],
583
- chat: ['nvidia/llama-4-maverick', 'nvidia/glm-4.7', 'nvidia/qwen3-coder-480b'],
584
- creative: ['nvidia/llama-4-maverick', 'nvidia/glm-4.7', 'nvidia/qwen3-coder-480b'],
583
+ coding: ['nvidia/qwen3-coder-480b', 'nvidia/llama-4-maverick'],
584
+ trading: ['nvidia/llama-4-maverick', 'nvidia/qwen3-coder-480b'],
585
+ research: ['nvidia/llama-4-maverick', 'nvidia/qwen3-coder-480b'],
586
+ reasoning: ['nvidia/llama-4-maverick', 'nvidia/qwen3-coder-480b'],
587
+ chat: ['nvidia/llama-4-maverick', 'nvidia/qwen3-coder-480b'],
588
+ creative: ['nvidia/llama-4-maverick', 'nvidia/qwen3-coder-480b'],
585
589
  };
586
590
  const DEFAULT_FREE_CHAIN = [
587
- 'nvidia/glm-4.7',
588
591
  'nvidia/llama-4-maverick',
589
592
  'nvidia/qwen3-coder-480b',
590
593
  ];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Cloud sync for desktop chat history — the local agent acts as the bridge.
3
+ *
4
+ * Identity is the local Base wallet (~/.blockrun). We run the SAME SIWE flow a
5
+ * browser does against franklin.run (/api/try/auth/nonce → sign → verify), hold
6
+ * the session, and proxy conversation load/save/delete to the existing
7
+ * /api/try/conversations API (GCS-backed, per-wallet). So the desktop and the
8
+ * web share one history keyed by wallet — and the web server needs no changes.
9
+ *
10
+ * Everything is best-effort: callers fall back to the local file on any failure
11
+ * (offline, not-deployed, auth hiccup), so cloud sync never breaks local use.
12
+ */
13
+ export interface CloudConversation {
14
+ id: string;
15
+ title: string;
16
+ createdAt: number;
17
+ updatedAt: number;
18
+ messages: unknown[];
19
+ }
20
+ export declare function isCloudSyncEnabled(): boolean;
21
+ export declare function cloudList(): Promise<CloudConversation[]>;
22
+ /** Reconcile cloud to match the given local list: upsert changed, delete removed. */
23
+ export declare function cloudSync(conversations: CloudConversation[]): Promise<void>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Cloud sync for desktop chat history — the local agent acts as the bridge.
3
+ *
4
+ * Identity is the local Base wallet (~/.blockrun). We run the SAME SIWE flow a
5
+ * browser does against franklin.run (/api/try/auth/nonce → sign → verify), hold
6
+ * the session, and proxy conversation load/save/delete to the existing
7
+ * /api/try/conversations API (GCS-backed, per-wallet). So the desktop and the
8
+ * web share one history keyed by wallet — and the web server needs no changes.
9
+ *
10
+ * Everything is best-effort: callers fall back to the local file on any failure
11
+ * (offline, not-deployed, auth hiccup), so cloud sync never breaks local use.
12
+ */
13
+ import { getOrCreateWallet } from '@blockrun/llm';
14
+ import { privateKeyToAccount } from 'viem/accounts';
15
+ const CLOUD_BASE = process.env.FRANKLIN_CLOUD_URL || 'https://franklin.run';
16
+ const NONCE_COOKIE = 'franklin_try_nonce';
17
+ const SESSION_COOKIE = 'franklin_try_session';
18
+ const TIMEOUT = 8000;
19
+ export function isCloudSyncEnabled() {
20
+ return process.env.FRANKLIN_CLOUD_SYNC !== 'off';
21
+ }
22
+ let sessionCookie = null;
23
+ // Track what we've pushed so save only sends changed/removed conversations.
24
+ let lastSynced = new Map();
25
+ function getSetCookie(res, name) {
26
+ const list = res.headers.getSetCookie?.() ?? [];
27
+ for (const c of list)
28
+ if (c.startsWith(name + '='))
29
+ return c.split(';')[0];
30
+ return null;
31
+ }
32
+ async function login() {
33
+ const nonceRes = await fetch(`${CLOUD_BASE}/api/try/auth/nonce`, { signal: AbortSignal.timeout(TIMEOUT) });
34
+ if (!nonceRes.ok)
35
+ throw new Error(`nonce ${nonceRes.status}`);
36
+ const nonceCookie = getSetCookie(nonceRes, NONCE_COOKIE);
37
+ const { nonce } = (await nonceRes.json());
38
+ if (!nonce || !nonceCookie)
39
+ throw new Error('no nonce');
40
+ const { privateKey, address } = getOrCreateWallet();
41
+ const account = privateKeyToAccount(privateKey);
42
+ const message = `Sign in to Franklin Desktop\n\nNonce: ${nonce}`;
43
+ const signature = await account.signMessage({ message });
44
+ const verifyRes = await fetch(`${CLOUD_BASE}/api/try/auth/verify`, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json', Cookie: nonceCookie },
47
+ body: JSON.stringify({ address, message, signature }),
48
+ signal: AbortSignal.timeout(TIMEOUT),
49
+ });
50
+ if (!verifyRes.ok)
51
+ throw new Error(`verify ${verifyRes.status}`);
52
+ const session = getSetCookie(verifyRes, SESSION_COOKIE);
53
+ if (!session)
54
+ throw new Error('no session cookie');
55
+ sessionCookie = session;
56
+ }
57
+ async function authed(path, init = {}) {
58
+ if (!sessionCookie)
59
+ await login();
60
+ const doFetch = () => fetch(`${CLOUD_BASE}${path}`, {
61
+ ...init,
62
+ headers: { ...(init.headers || {}), Cookie: sessionCookie },
63
+ signal: AbortSignal.timeout(TIMEOUT),
64
+ });
65
+ let res = await doFetch();
66
+ if (res.status === 401) {
67
+ sessionCookie = null;
68
+ await login();
69
+ res = await doFetch();
70
+ }
71
+ return res;
72
+ }
73
+ export async function cloudList() {
74
+ const res = await authed('/api/try/conversations');
75
+ if (!res.ok)
76
+ throw new Error(`list ${res.status}`);
77
+ const j = (await res.json());
78
+ const convos = Array.isArray(j.conversations) ? j.conversations : [];
79
+ lastSynced = new Map(convos.map((c) => [c.id, c.updatedAt]));
80
+ return convos;
81
+ }
82
+ async function cloudPut(c) {
83
+ const res = await authed(`/api/try/conversations/${encodeURIComponent(c.id)}`, {
84
+ method: 'PUT',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify(c),
87
+ });
88
+ if (!res.ok)
89
+ throw new Error(`put ${res.status}`);
90
+ }
91
+ async function cloudDelete(id) {
92
+ try {
93
+ await authed(`/api/try/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' });
94
+ }
95
+ catch { /* ignore */ }
96
+ }
97
+ // Sync passes are serialized: cloudSync is called fire-and-forget from both
98
+ // history.load (migration) and history.save, and a pass reads + rewrites the
99
+ // module-level `lastSynced` map across awaited network calls. Two interleaved
100
+ // passes corrupt that shared state — worst case the delete sweep walks a stale
101
+ // snapshot and removes a conversation a concurrent pass just uploaded.
102
+ let syncQueue = Promise.resolve();
103
+ /** Reconcile cloud to match the given local list: upsert changed, delete removed. */
104
+ export function cloudSync(conversations) {
105
+ const run = syncQueue.then(() => doCloudSync(conversations));
106
+ syncQueue = run.catch(() => { }); // keep the chain alive after a failed pass
107
+ return run;
108
+ }
109
+ async function doCloudSync(conversations) {
110
+ const current = new Map(conversations.map((c) => [c.id, c.updatedAt]));
111
+ for (const c of conversations) {
112
+ if (lastSynced.get(c.id) !== c.updatedAt)
113
+ await cloudPut(c);
114
+ }
115
+ for (const id of [...lastSynced.keys()]) {
116
+ if (!current.has(id))
117
+ await cloudDelete(id);
118
+ }
119
+ lastSynced = current;
120
+ }
@@ -0,0 +1,25 @@
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
+ interface ServerOptions {
20
+ port: number;
21
+ workDir: string;
22
+ debug?: boolean;
23
+ }
24
+ export declare function startServer(opts: ServerOptions): Promise<void>;
25
+ export {};