@blockrun/franklin 3.2.4 → 3.3.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.
Files changed (61) hide show
  1. package/README.md +216 -233
  2. package/dist/agent/commands.js +54 -13
  3. package/dist/agent/context.js +31 -1
  4. package/dist/agent/loop.js +48 -19
  5. package/dist/agent/permissions.js +3 -3
  6. package/dist/commands/migrate.d.ts +13 -0
  7. package/dist/commands/migrate.js +389 -0
  8. package/dist/commands/panel.d.ts +6 -0
  9. package/dist/commands/panel.js +29 -0
  10. package/dist/commands/start.js +41 -2
  11. package/dist/events/bridge.d.ts +1 -0
  12. package/dist/events/bridge.js +24 -0
  13. package/dist/events/bus.d.ts +17 -0
  14. package/dist/events/bus.js +55 -0
  15. package/dist/events/types.d.ts +49 -0
  16. package/dist/events/types.js +8 -0
  17. package/dist/index.js +15 -0
  18. package/dist/learnings/extractor.d.ts +16 -0
  19. package/dist/learnings/extractor.js +234 -0
  20. package/dist/learnings/index.d.ts +3 -0
  21. package/dist/learnings/index.js +2 -0
  22. package/dist/learnings/store.d.ts +15 -0
  23. package/dist/learnings/store.js +130 -0
  24. package/dist/learnings/types.d.ts +24 -0
  25. package/dist/learnings/types.js +7 -0
  26. package/dist/mcp/client.js +9 -2
  27. package/dist/narrative/state.d.ts +30 -0
  28. package/dist/narrative/state.js +69 -0
  29. package/dist/panel/html.d.ts +5 -0
  30. package/dist/panel/html.js +341 -0
  31. package/dist/panel/server.d.ts +7 -0
  32. package/dist/panel/server.js +152 -0
  33. package/dist/session/storage.js +4 -2
  34. package/dist/social/browser-pool.d.ts +29 -0
  35. package/dist/social/browser-pool.js +57 -0
  36. package/dist/social/preflight.d.ts +14 -0
  37. package/dist/social/preflight.js +26 -0
  38. package/dist/social/x.d.ts +8 -0
  39. package/dist/social/x.js +9 -1
  40. package/dist/stats/tracker.d.ts +1 -0
  41. package/dist/stats/tracker.js +59 -13
  42. package/dist/tools/bash.js +6 -1
  43. package/dist/tools/index.js +3 -0
  44. package/dist/tools/posttox.d.ts +7 -0
  45. package/dist/tools/posttox.js +137 -0
  46. package/dist/tools/searchx.d.ts +7 -0
  47. package/dist/tools/searchx.js +111 -0
  48. package/dist/tools/trading.d.ts +3 -0
  49. package/dist/tools/trading.js +168 -0
  50. package/dist/tools/webfetch.js +19 -9
  51. package/dist/tools/write.js +2 -0
  52. package/dist/trading/config.d.ts +23 -0
  53. package/dist/trading/config.js +45 -0
  54. package/dist/trading/data.d.ts +30 -0
  55. package/dist/trading/data.js +112 -0
  56. package/dist/trading/metrics.d.ts +29 -0
  57. package/dist/trading/metrics.js +105 -0
  58. package/dist/ui/app.js +73 -44
  59. package/dist/ui/markdown.d.ts +9 -0
  60. package/dist/ui/markdown.js +86 -0
  61. package/package.json +1 -1
@@ -0,0 +1,389 @@
1
+ /**
2
+ * franklin migrate — one-click import from other AI coding agents.
3
+ *
4
+ * Detects installed tools (Claude Code, Cline, Cursor, etc.),
5
+ * shows what can be migrated, and imports with user confirmation.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import readline from 'node:readline';
11
+ import chalk from 'chalk';
12
+ import { BLOCKRUN_DIR } from '../config.js';
13
+ function detectSources() {
14
+ const sources = [];
15
+ const home = os.homedir();
16
+ // ── Claude Code ──
17
+ const claudeDir = path.join(home, '.claude');
18
+ if (fs.existsSync(claudeDir)) {
19
+ const items = [];
20
+ // MCP servers
21
+ const claudeMcp = path.join(claudeDir, 'mcp.json');
22
+ if (fs.existsSync(claudeMcp)) {
23
+ items.push({
24
+ label: 'MCP servers',
25
+ source: claudeMcp,
26
+ target: path.join(BLOCKRUN_DIR, 'mcp.json'),
27
+ size: fileSize(claudeMcp),
28
+ transform: () => migrateMcp(claudeMcp),
29
+ });
30
+ }
31
+ // Global instructions → learnings
32
+ const claudeMd = path.join(claudeDir, 'CLAUDE.md');
33
+ if (fs.existsSync(claudeMd)) {
34
+ items.push({
35
+ label: 'Global instructions (CLAUDE.md)',
36
+ source: claudeMd,
37
+ target: path.join(BLOCKRUN_DIR, 'learnings.jsonl'),
38
+ size: fileSize(claudeMd),
39
+ transform: () => migrateInstructions(claudeMd),
40
+ });
41
+ }
42
+ // Session history
43
+ const claudeHistory = path.join(claudeDir, 'history.jsonl');
44
+ if (fs.existsSync(claudeHistory)) {
45
+ const lines = countLines(claudeHistory);
46
+ items.push({
47
+ label: `Session history (${lines.toLocaleString()} messages)`,
48
+ source: claudeHistory,
49
+ target: path.join(BLOCKRUN_DIR, 'sessions'),
50
+ size: fileSize(claudeHistory),
51
+ transform: () => migrateSessions(claudeHistory),
52
+ });
53
+ }
54
+ // Project memory files
55
+ const projectsDir = path.join(claudeDir, 'projects');
56
+ if (fs.existsSync(projectsDir)) {
57
+ const memoryFiles = findMemoryFiles(projectsDir);
58
+ if (memoryFiles.length > 0) {
59
+ items.push({
60
+ label: `Project memories (${memoryFiles.length} files)`,
61
+ source: projectsDir,
62
+ target: path.join(BLOCKRUN_DIR, 'learnings.jsonl'),
63
+ size: `${memoryFiles.length} files`,
64
+ transform: () => migrateMemories(memoryFiles),
65
+ });
66
+ }
67
+ }
68
+ if (items.length > 0) {
69
+ sources.push({ name: 'Claude Code', dir: claudeDir, items });
70
+ }
71
+ }
72
+ // ── Cline / OpenClaw ──
73
+ const clineDir = path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev');
74
+ if (fs.existsSync(clineDir)) {
75
+ const items = [];
76
+ // TODO: detect Cline data
77
+ if (items.length > 0) {
78
+ sources.push({ name: 'Cline', dir: clineDir, items });
79
+ }
80
+ }
81
+ // ── Cursor ──
82
+ const cursorDir = path.join(home, 'Library', 'Application Support', 'Cursor');
83
+ if (fs.existsSync(cursorDir)) {
84
+ const items = [];
85
+ // TODO: detect Cursor data
86
+ if (items.length > 0) {
87
+ sources.push({ name: 'Cursor', dir: cursorDir, items });
88
+ }
89
+ }
90
+ return sources;
91
+ }
92
+ // ─── Transforms ───────────────────────────────────────────────────────────
93
+ function migrateMcp(source) {
94
+ const target = path.join(BLOCKRUN_DIR, 'mcp.json');
95
+ const raw = JSON.parse(fs.readFileSync(source, 'utf-8'));
96
+ // Claude Code format: { mcpServers: { name: { command, args, env } } }
97
+ // Franklin format: { mcpServers: { name: { transport, command, args, label } } }
98
+ const servers = {};
99
+ if (raw.mcpServers) {
100
+ for (const [name, config] of Object.entries(raw.mcpServers)) {
101
+ servers[name] = {
102
+ transport: config.transport || 'stdio',
103
+ command: config.command,
104
+ args: config.args || [],
105
+ label: name,
106
+ ...(config.env ? { env: config.env } : {}),
107
+ };
108
+ }
109
+ }
110
+ // Merge with existing Franklin MCP config
111
+ let existing = {};
112
+ try {
113
+ if (fs.existsSync(target)) {
114
+ existing = JSON.parse(fs.readFileSync(target, 'utf-8'));
115
+ }
116
+ }
117
+ catch { /* start fresh */ }
118
+ const merged = {
119
+ mcpServers: {
120
+ ...(existing.mcpServers || {}),
121
+ ...servers,
122
+ },
123
+ };
124
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
125
+ fs.writeFileSync(target, JSON.stringify(merged, null, 2));
126
+ console.log(chalk.green(` ✓ ${Object.keys(servers).length} MCP server(s) imported`));
127
+ }
128
+ function migrateInstructions(source) {
129
+ // Read CLAUDE.md and convert key preferences to learnings
130
+ const content = fs.readFileSync(source, 'utf-8');
131
+ const learningsPath = path.join(BLOCKRUN_DIR, 'learnings.jsonl');
132
+ // Extract simple preference lines as learnings
133
+ const lines = content.split('\n');
134
+ const learnings = [];
135
+ const now = Date.now();
136
+ let count = 0;
137
+ for (const line of lines) {
138
+ const trimmed = line.trim();
139
+ // Skip empty lines, headers, and code blocks
140
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('```') || trimmed.startsWith('|'))
141
+ continue;
142
+ // Skip very short or very long lines
143
+ if (trimmed.length < 15 || trimmed.length > 200)
144
+ continue;
145
+ // Skip lines that are just paths or URLs
146
+ if (trimmed.startsWith('/') || trimmed.startsWith('http'))
147
+ continue;
148
+ // Lines starting with - or * are likely preference rules
149
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
150
+ const text = trimmed.slice(2).trim();
151
+ if (text.length > 15) {
152
+ const entry = {
153
+ id: `migrate-${count++}`,
154
+ learning: text.slice(0, 200),
155
+ category: 'other',
156
+ confidence: 0.8,
157
+ source_session: 'migrate:claude-code',
158
+ created_at: now,
159
+ last_confirmed: now,
160
+ times_confirmed: 1,
161
+ };
162
+ learnings.push(JSON.stringify(entry));
163
+ }
164
+ }
165
+ }
166
+ if (learnings.length > 0) {
167
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
168
+ // Append to existing learnings
169
+ fs.appendFileSync(learningsPath, learnings.join('\n') + '\n');
170
+ console.log(chalk.green(` ✓ ${learnings.length} preferences imported`));
171
+ }
172
+ else {
173
+ console.log(chalk.dim(' ○ No extractable preferences found'));
174
+ }
175
+ }
176
+ function migrateSessions(source) {
177
+ const sessionsDir = path.join(BLOCKRUN_DIR, 'sessions');
178
+ fs.mkdirSync(sessionsDir, { recursive: true });
179
+ const raw = fs.readFileSync(source, 'utf-8');
180
+ const lines = raw.split('\n').filter(l => l.trim());
181
+ // Group by conversation turns — each user+assistant pair is a chunk
182
+ // We'll create session files grouped by day
183
+ const sessions = new Map();
184
+ for (const line of lines) {
185
+ try {
186
+ const msg = JSON.parse(line);
187
+ // Use date from the line or current date as session key
188
+ const dateKey = new Date().toISOString().split('T')[0];
189
+ // Try to extract timestamp if present
190
+ const ts = msg.timestamp || msg.created_at || msg.ts;
191
+ const key = ts ? new Date(ts).toISOString().split('T')[0] : dateKey;
192
+ if (!sessions.has(key))
193
+ sessions.set(key, []);
194
+ sessions.get(key).push(line);
195
+ }
196
+ catch {
197
+ // Skip unparseable lines
198
+ }
199
+ }
200
+ let imported = 0;
201
+ for (const [dateKey, msgs] of sessions) {
202
+ const sessionId = `imported-${dateKey}`;
203
+ const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`);
204
+ // Don't overwrite existing imported sessions
205
+ if (fs.existsSync(sessionFile))
206
+ continue;
207
+ fs.writeFileSync(sessionFile, msgs.join('\n') + '\n');
208
+ // Create metadata
209
+ const meta = {
210
+ id: sessionId,
211
+ model: 'imported',
212
+ workDir: os.homedir(),
213
+ createdAt: new Date(dateKey).getTime(),
214
+ updatedAt: Date.now(),
215
+ turnCount: Math.floor(msgs.length / 2),
216
+ messageCount: msgs.length,
217
+ };
218
+ fs.writeFileSync(path.join(sessionsDir, `${sessionId}.meta.json`), JSON.stringify(meta, null, 2));
219
+ imported++;
220
+ }
221
+ console.log(chalk.green(` ✓ ${lines.length.toLocaleString()} messages → ${imported} session(s)`));
222
+ }
223
+ function migrateMemories(files) {
224
+ const learningsPath = path.join(BLOCKRUN_DIR, 'learnings.jsonl');
225
+ const now = Date.now();
226
+ let count = 0;
227
+ const entries = [];
228
+ for (const file of files) {
229
+ try {
230
+ const content = fs.readFileSync(file, 'utf-8');
231
+ const lines = content.split('\n');
232
+ for (const line of lines) {
233
+ const trimmed = line.trim();
234
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('```'))
235
+ continue;
236
+ if (trimmed.startsWith('- ') && trimmed.length > 20 && trimmed.length < 200) {
237
+ const text = trimmed.slice(2).trim();
238
+ // Skip index entries (links to other files)
239
+ if (text.startsWith('[') && text.includes(']('))
240
+ continue;
241
+ entries.push(JSON.stringify({
242
+ id: `memory-${count++}`,
243
+ learning: text.slice(0, 200),
244
+ category: 'other',
245
+ confidence: 0.7,
246
+ source_session: 'migrate:project-memory',
247
+ created_at: now,
248
+ last_confirmed: now,
249
+ times_confirmed: 1,
250
+ }));
251
+ }
252
+ }
253
+ }
254
+ catch { /* skip unreadable files */ }
255
+ }
256
+ if (entries.length > 0) {
257
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
258
+ fs.appendFileSync(learningsPath, entries.join('\n') + '\n');
259
+ console.log(chalk.green(` ✓ ${entries.length} memories imported`));
260
+ }
261
+ else {
262
+ console.log(chalk.dim(' ○ No extractable memories found'));
263
+ }
264
+ }
265
+ // ─── Helpers ──────────────────────────────────────────────────────────────
266
+ function fileSize(p) {
267
+ try {
268
+ const bytes = fs.statSync(p).size;
269
+ if (bytes < 1024)
270
+ return `${bytes} B`;
271
+ if (bytes < 1024 * 1024)
272
+ return `${(bytes / 1024).toFixed(1)} KB`;
273
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
274
+ }
275
+ catch {
276
+ return '?';
277
+ }
278
+ }
279
+ function countLines(p) {
280
+ try {
281
+ return fs.readFileSync(p, 'utf-8').split('\n').filter(l => l.trim()).length;
282
+ }
283
+ catch {
284
+ return 0;
285
+ }
286
+ }
287
+ function findMemoryFiles(projectsDir) {
288
+ const files = [];
289
+ try {
290
+ for (const project of fs.readdirSync(projectsDir)) {
291
+ const memoryDir = path.join(projectsDir, project, 'memory');
292
+ if (!fs.existsSync(memoryDir))
293
+ continue;
294
+ for (const file of fs.readdirSync(memoryDir)) {
295
+ if (file.endsWith('.md') && file !== 'MEMORY.md') {
296
+ files.push(path.join(memoryDir, file));
297
+ }
298
+ }
299
+ }
300
+ }
301
+ catch { /* ignore */ }
302
+ return files;
303
+ }
304
+ // ─── Interactive prompt ───────────────────────────────────────────────────
305
+ function ask(question) {
306
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
307
+ return new Promise(resolve => {
308
+ rl.question(question, answer => { rl.close(); resolve(answer.trim().toLowerCase()); });
309
+ });
310
+ }
311
+ // ─── Main command ─────────────────────────────────────────────────────────
312
+ export async function migrateCommand() {
313
+ console.log(chalk.bold('\n franklin migrate\n'));
314
+ const sources = detectSources();
315
+ if (sources.length === 0) {
316
+ console.log(chalk.dim(' No other AI tools detected. Nothing to migrate.\n'));
317
+ console.log(chalk.dim(' Supported: Claude Code, Cline, Cursor\n'));
318
+ return;
319
+ }
320
+ // Show what was found
321
+ for (const source of sources) {
322
+ console.log(chalk.bold(` ${chalk.green('●')} ${source.name}`) + chalk.dim(` (${source.dir})`));
323
+ for (const item of source.items) {
324
+ console.log(chalk.dim(` ├─ ${item.label}`) + (item.size ? chalk.dim(` [${item.size}]`) : ''));
325
+ }
326
+ console.log('');
327
+ }
328
+ const total = sources.reduce((n, s) => n + s.items.length, 0);
329
+ const answer = await ask(chalk.yellow(` Import ${total} item(s) into Franklin? [Y/n] `));
330
+ if (answer && answer !== 'y' && answer !== 'yes') {
331
+ console.log(chalk.dim('\n Cancelled.\n'));
332
+ return;
333
+ }
334
+ console.log('');
335
+ // Run migrations
336
+ for (const source of sources) {
337
+ console.log(chalk.bold(` Migrating from ${source.name}...`));
338
+ for (const item of source.items) {
339
+ try {
340
+ item.transform();
341
+ }
342
+ catch (err) {
343
+ console.log(chalk.red(` ✗ ${item.label}: ${err.message}`));
344
+ }
345
+ }
346
+ console.log('');
347
+ }
348
+ console.log(chalk.green(' Done.') + chalk.dim(' Run `franklin --trust` to start.\n'));
349
+ }
350
+ // ─── First-run detection (called from start.ts) ──────────────────────────
351
+ const MIGRATED_MARKER = path.join(BLOCKRUN_DIR, '.migrated');
352
+ /**
353
+ * Check if other AI tools are installed and suggest migration.
354
+ * Only runs once — writes a marker file after first check.
355
+ * Returns true if the user chose to migrate (caller should re-run start after).
356
+ */
357
+ export async function checkAndSuggestMigration() {
358
+ // Only suggest once
359
+ if (fs.existsSync(MIGRATED_MARKER))
360
+ return false;
361
+ // Write marker immediately so we never ask again
362
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
363
+ fs.writeFileSync(MIGRATED_MARKER, new Date().toISOString());
364
+ const sources = detectSources();
365
+ if (sources.length === 0)
366
+ return false;
367
+ const names = sources.map(s => s.name).join(', ');
368
+ const total = sources.reduce((n, s) => n + s.items.length, 0);
369
+ console.log(chalk.bold(`\n ${chalk.green('●')} Found ${names} — ${total} items available to import.`));
370
+ const answer = await ask(chalk.yellow(` Import into Franklin? [Y/n] `));
371
+ if (answer && answer !== 'y' && answer !== 'yes') {
372
+ console.log(chalk.dim(' Skipped. Run `franklin migrate` anytime.\n'));
373
+ return false;
374
+ }
375
+ console.log('');
376
+ for (const source of sources) {
377
+ console.log(chalk.bold(` Migrating from ${source.name}...`));
378
+ for (const item of source.items) {
379
+ try {
380
+ item.transform();
381
+ }
382
+ catch (err) {
383
+ console.log(chalk.red(` ✗ ${item.label}: ${err.message}`));
384
+ }
385
+ }
386
+ }
387
+ console.log(chalk.green('\n Done.') + ' Starting Franklin...\n');
388
+ return true;
389
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * franklin panel — launch the local web dashboard.
3
+ */
4
+ export declare function panelCommand(options: {
5
+ port?: string;
6
+ }): Promise<void>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * franklin panel — launch the local web dashboard.
3
+ */
4
+ import chalk from 'chalk';
5
+ import { createPanelServer } from '../panel/server.js';
6
+ export async function panelCommand(options) {
7
+ const port = parseInt(options.port || '3100', 10);
8
+ const server = createPanelServer(port);
9
+ server.listen(port, () => {
10
+ console.log('');
11
+ console.log(chalk.bold(' Franklin Panel'));
12
+ console.log(chalk.dim(` http://localhost:${port}`));
13
+ console.log('');
14
+ console.log(chalk.dim(' Press Ctrl+C to stop.'));
15
+ console.log('');
16
+ // Try to open browser
17
+ const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
18
+ import('node:child_process').then(({ exec }) => {
19
+ exec(`${open} http://localhost:${port}`);
20
+ }).catch(() => { });
21
+ });
22
+ // Graceful shutdown
23
+ const shutdown = () => {
24
+ server.close();
25
+ process.exit(0);
26
+ };
27
+ process.on('SIGINT', shutdown);
28
+ process.on('SIGTERM', shutdown);
29
+ }
@@ -50,16 +50,33 @@ export async function startCommand(options) {
50
50
  console.log(chalk.dim(' Free models work now. Fund with USDC for paid models.\n'));
51
51
  }
52
52
  }
53
+ // First-run: detect other AI tools and offer migration
54
+ if (process.stdin.isTTY) {
55
+ try {
56
+ const { checkAndSuggestMigration } = await import('./migrate.js');
57
+ await checkAndSuggestMigration();
58
+ }
59
+ catch { /* migration is optional */ }
60
+ }
53
61
  printBanner(version);
54
62
  const workDir = process.cwd();
55
63
  // Show session info immediately, fetch balance in background
56
- console.log(chalk.dim(` Model: ${model}`));
64
+ // Model is shown in the live status bar — no static line needed.
57
65
  console.log(chalk.dim(` Wallet: ${walletAddress || 'not set'}`));
58
66
  console.log(chalk.dim(` Dir: ${workDir}`));
59
67
  // First-run tip: show if no config file exists yet
60
68
  if (!configModel && !options.model) {
61
69
  console.log(chalk.dim(`\n Tip: /model to switch models · /compact to save tokens · /help for all commands`));
62
70
  }
71
+ // Welcome message — show things Hermes/OpenClaw can't do.
72
+ // Only on first run or when no model is configured (new user indicator).
73
+ // After the user's first session, the tip fades and they go straight to the prompt.
74
+ console.log('');
75
+ console.log(chalk.dim(' Try something only Franklin can do:'));
76
+ console.log(chalk.dim(' ') + chalk.hex('#FFD700')('"what\'s BTC looking like today?"') + chalk.dim(' ← market signal'));
77
+ console.log(chalk.dim(' ') + chalk.hex('#10B981')('"find X posts about ai agent"') + chalk.dim(' ← social growth'));
78
+ console.log(chalk.dim(' ') + chalk.hex('#60A5FA')('"generate a hero image for my app"') + chalk.dim(' ← image gen'));
79
+ console.log(chalk.dim(' Or just code — 55+ models, no API keys.'));
63
80
  console.log('');
64
81
  // Balance fetcher — used at startup and after each turn
65
82
  const fetchBalance = async () => {
@@ -130,6 +147,14 @@ export async function startCommand(options) {
130
147
  permissionMode: (options.trust || !process.stdin.isTTY) ? 'trust' : 'default',
131
148
  debug: options.debug,
132
149
  };
150
+ // Bootstrap learnings from Claude Code config on first run (async, non-blocking)
151
+ Promise.all([
152
+ import('../learnings/extractor.js'),
153
+ import('../agent/llm.js'),
154
+ ]).then(([{ bootstrapFromClaudeConfig }, { ModelClient }]) => {
155
+ const client = new ModelClient({ apiUrl, chain });
156
+ bootstrapFromClaudeConfig(client).catch(() => { });
157
+ }).catch(() => { });
133
158
  // Use Ink UI if TTY, fallback to basic readline for piped input
134
159
  if (process.stdin.isTTY) {
135
160
  await runWithInkUI(agentConfig, model, workDir, version, walletInfo, (cb) => {
@@ -167,8 +192,9 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
167
192
  fetchBalance().then(bal => ui.updateBalance(bal)).catch(() => { });
168
193
  });
169
194
  }
195
+ let sessionHistory;
170
196
  try {
171
- await interactiveSession(agentConfig, async () => {
197
+ sessionHistory = await interactiveSession(agentConfig, async () => {
172
198
  const input = await ui.waitForInput();
173
199
  if (input === null)
174
200
  return null;
@@ -184,6 +210,19 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
184
210
  }
185
211
  ui.cleanup();
186
212
  flushStats();
213
+ // Extract learnings from the session (async, 10s timeout, never blocks exit)
214
+ if (sessionHistory && sessionHistory.length >= 4) {
215
+ try {
216
+ const { extractLearnings } = await import('../learnings/extractor.js');
217
+ const { ModelClient } = await import('../agent/llm.js');
218
+ const client = new ModelClient({ apiUrl: agentConfig.apiUrl, chain: agentConfig.chain });
219
+ await Promise.race([
220
+ extractLearnings(sessionHistory, `session-${new Date().toISOString()}`, client),
221
+ new Promise(resolve => setTimeout(resolve, 10_000)),
222
+ ]);
223
+ }
224
+ catch { /* extraction is best-effort */ }
225
+ }
187
226
  await disconnectMcpServers();
188
227
  console.log(chalk.dim('\nGoodbye.\n'));
189
228
  }
@@ -0,0 +1 @@
1
+ export declare function initBridge(): void;
@@ -0,0 +1,24 @@
1
+ import { bus } from './bus.js';
2
+ import { addSignal, addPost } from '../narrative/state.js';
3
+ export function initBridge() {
4
+ bus.on('signal.detected', (event) => {
5
+ const e = event;
6
+ addSignal({
7
+ asset: e.data.asset,
8
+ direction: e.data.direction,
9
+ confidence: e.data.confidence,
10
+ summary: e.data.summary,
11
+ ts: e.ts,
12
+ });
13
+ });
14
+ bus.on('post.published', (event) => {
15
+ const e = event;
16
+ addPost({
17
+ platform: e.data.platform,
18
+ url: e.data.url,
19
+ text: e.data.text,
20
+ referencesAssets: e.data.referencesAssets,
21
+ ts: e.ts,
22
+ });
23
+ });
24
+ }
@@ -0,0 +1,17 @@
1
+ import type { FranklinEvent } from './types.js';
2
+ type Handler = (event: FranklinEvent) => void | Promise<void>;
3
+ export declare class EventBus {
4
+ private handlers;
5
+ private logEnabled;
6
+ private logPath;
7
+ constructor(opts?: {
8
+ log?: boolean;
9
+ });
10
+ on(type: FranklinEvent['type'], handler: Handler): void;
11
+ off(type: FranklinEvent['type'], handler: Handler): void;
12
+ emit(event: FranklinEvent): Promise<void>;
13
+ clear(): void;
14
+ private appendLog;
15
+ }
16
+ export declare const bus: EventBus;
17
+ export {};
@@ -0,0 +1,55 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ export class EventBus {
5
+ handlers = new Map();
6
+ logEnabled;
7
+ logPath;
8
+ constructor(opts = {}) {
9
+ this.logEnabled = opts.log ?? false;
10
+ this.logPath = path.join(os.homedir(), '.blockrun', 'events.jsonl');
11
+ }
12
+ on(type, handler) {
13
+ let set = this.handlers.get(type);
14
+ if (!set) {
15
+ set = new Set();
16
+ this.handlers.set(type, set);
17
+ }
18
+ set.add(handler);
19
+ }
20
+ off(type, handler) {
21
+ this.handlers.get(type)?.delete(handler);
22
+ }
23
+ async emit(event) {
24
+ if (this.logEnabled) {
25
+ this.appendLog(event);
26
+ }
27
+ const set = this.handlers.get(event.type);
28
+ if (!set)
29
+ return;
30
+ const promises = [];
31
+ for (const handler of set) {
32
+ const result = handler(event);
33
+ if (result)
34
+ promises.push(result);
35
+ }
36
+ if (promises.length)
37
+ await Promise.all(promises);
38
+ }
39
+ clear() {
40
+ this.handlers.clear();
41
+ }
42
+ appendLog(event) {
43
+ try {
44
+ const dir = path.dirname(this.logPath);
45
+ if (!fs.existsSync(dir)) {
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ }
48
+ fs.appendFileSync(this.logPath, JSON.stringify(event) + '\n');
49
+ }
50
+ catch {
51
+ // best-effort logging — don't crash the agent
52
+ }
53
+ }
54
+ }
55
+ export const bus = new EventBus();
@@ -0,0 +1,49 @@
1
+ export interface BaseEvent {
2
+ id: string;
3
+ type: string;
4
+ ts: string;
5
+ source: 'trading' | 'social' | 'core';
6
+ costUsd?: number;
7
+ correlationId?: string;
8
+ }
9
+ export interface SignalDetectedEvent extends BaseEvent {
10
+ type: 'signal.detected';
11
+ data: {
12
+ asset: string;
13
+ direction: 'bullish' | 'bearish' | 'neutral';
14
+ confidence: number;
15
+ indicators: Record<string, number>;
16
+ summary: string;
17
+ };
18
+ }
19
+ export interface PostPublishedEvent extends BaseEvent {
20
+ type: 'post.published';
21
+ data: {
22
+ platform: 'x' | 'reddit' | (string & {});
23
+ url: string;
24
+ text: string;
25
+ referencesAssets?: string[];
26
+ };
27
+ }
28
+ export interface MentionReceivedEvent extends BaseEvent {
29
+ type: 'mention.received';
30
+ data: {
31
+ platform: string;
32
+ url: string;
33
+ text: string;
34
+ author: string;
35
+ sentiment?: 'positive' | 'negative' | 'neutral';
36
+ mentionsAsset?: string;
37
+ };
38
+ }
39
+ export interface BudgetExceededEvent extends BaseEvent {
40
+ type: 'budget.exceeded';
41
+ data: {
42
+ category: 'llm' | 'data' | 'gas';
43
+ spent: number;
44
+ cap: number;
45
+ blockedAction: string;
46
+ };
47
+ }
48
+ export type FranklinEvent = SignalDetectedEvent | PostPublishedEvent | MentionReceivedEvent | BudgetExceededEvent;
49
+ export declare function makeEvent<T extends FranklinEvent>(props: Omit<T, 'id' | 'ts'>): T;
@@ -0,0 +1,8 @@
1
+ import crypto from 'node:crypto';
2
+ export function makeEvent(props) {
3
+ return {
4
+ id: crypto.randomUUID(),
5
+ ts: new Date().toISOString(),
6
+ ...props,
7
+ };
8
+ }