@blockrun/franklin 3.26.1 → 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.
- package/dist/agent/llm.js +9 -1
- package/dist/agent/loop.js +14 -4
- package/dist/agent/streaming-executor.js +28 -2
- package/dist/agent/types.d.ts +4 -0
- package/dist/channel/slack.d.ts +57 -0
- package/dist/channel/slack.js +370 -0
- package/dist/channel/telegram.d.ts +4 -0
- package/dist/channel/telegram.js +85 -16
- package/dist/commands/config.d.ts +7 -0
- package/dist/commands/config.js +7 -0
- package/dist/commands/serve.d.ts +7 -0
- package/dist/commands/serve.js +7 -0
- package/dist/commands/slack.d.ts +15 -0
- package/dist/commands/slack.js +118 -0
- package/dist/commands/telegram.js +9 -0
- package/dist/index.js +23 -1
- package/dist/serve/cloud-sync.d.ts +23 -0
- package/dist/serve/cloud-sync.js +120 -0
- package/dist/serve/server.d.ts +25 -0
- package/dist/serve/server.js +767 -0
- package/dist/stats/swap-log.d.ts +19 -0
- package/dist/stats/swap-log.js +33 -0
- package/dist/tools/zerox-base.js +36 -1
- package/dist/tools/zerox-gasless.js +17 -0
- package/package.json +2 -1
package/dist/channel/telegram.js
CHANGED
|
@@ -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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
void sendMessage(
|
|
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
|
-
|
|
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
|
-
|
|
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] ← ${
|
|
361
|
+
log(`[telegram] ← ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}`);
|
|
293
362
|
// Intercept bot slash commands before handing off to the agent.
|
|
294
|
-
if (
|
|
363
|
+
if (text.trim().startsWith('/')) {
|
|
295
364
|
state.currentChatId = msg.chat.id;
|
|
296
|
-
const handled = await handleSlashCommand(msg.chat.id,
|
|
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,
|
|
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 {};
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
+
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
|
-
|
|
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
|
}
|
|
@@ -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 {};
|