@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,341 @@
1
+ /**
2
+ * Franklin Panel — embedded HTML dashboard.
3
+ * Single page, dark theme, zero dependencies.
4
+ */
5
+ export function getHTML() {
6
+ return `<!DOCTYPE html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>Franklin Panel</title>
12
+ <style>
13
+ :root {
14
+ --bg: #0a0a0f;
15
+ --bg-card: #12121a;
16
+ --bg-hover: #1a1a2a;
17
+ --border: #2a2a3a;
18
+ --text: #e0e0e8;
19
+ --text-dim: #6a6a7a;
20
+ --accent: #10b981;
21
+ --gold: #ffd700;
22
+ --blue: #60a5fa;
23
+ --danger: #ef4444;
24
+ --mono: 'SF Mono','Fira Code','Cascadia Code','Menlo',monospace;
25
+ --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
26
+ }
27
+ * { margin:0; padding:0; box-sizing:border-box; }
28
+ body { background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; }
29
+ a { color:var(--blue); text-decoration:none; }
30
+ a:hover { text-decoration:underline; }
31
+
32
+ header {
33
+ display:flex; align-items:center; justify-content:space-between;
34
+ padding:16px 24px; border-bottom:1px solid var(--border);
35
+ }
36
+ header h1 { font-size:18px; font-weight:600; }
37
+ header h1 span { color:var(--gold); }
38
+ .dot { width:8px; height:8px; border-radius:50%; display:inline-block; margin-left:8px; }
39
+ .dot.on { background:var(--accent); animation:pulse 2s infinite; }
40
+ .dot.off { background:var(--danger); }
41
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
42
+
43
+ nav { display:flex; gap:0; border-bottom:1px solid var(--border); padding:0 24px; }
44
+ nav button {
45
+ background:none; border:none; color:var(--text-dim); padding:12px 20px;
46
+ cursor:pointer; font-size:14px; border-bottom:2px solid transparent;
47
+ transition:all .15s;
48
+ }
49
+ nav button:hover { color:var(--text); }
50
+ nav button.active { color:var(--accent); border-bottom-color:var(--accent); }
51
+
52
+ main { padding:24px; max-width:1200px; margin:0 auto; }
53
+ .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px; }
54
+ .card {
55
+ background:var(--bg-card); border:1px solid var(--border);
56
+ border-radius:8px; padding:16px 20px;
57
+ }
58
+ .card h3 { font-size:12px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.5px; margin-bottom:12px; }
59
+ .big { font-size:28px; font-weight:700; font-family:var(--mono); }
60
+ .big.gold { color:var(--gold); }
61
+ .big.green { color:var(--accent); }
62
+ .sub { font-size:12px; color:var(--text-dim); margin-top:4px; }
63
+
64
+ .bar-chart { display:flex; flex-direction:column; gap:6px; }
65
+ .bar-row { display:flex; align-items:center; gap:8px; font-size:12px; }
66
+ .bar-label { width:140px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-dim); font-family:var(--mono); }
67
+ .bar-fill { height:16px; border-radius:3px; background:var(--accent); min-width:2px; transition:width .3s; }
68
+ .bar-val { font-family:var(--mono); color:var(--text-dim); font-size:11px; }
69
+
70
+ .daily-chart { display:flex; align-items:flex-end; gap:2px; height:80px; }
71
+ .daily-bar { flex:1; background:var(--accent); border-radius:2px 2px 0 0; min-height:2px; transition:height .3s; opacity:.7; }
72
+ .daily-bar:hover { opacity:1; }
73
+
74
+ .session-list { display:flex; flex-direction:column; gap:8px; }
75
+ .session-item {
76
+ background:var(--bg-card); border:1px solid var(--border); border-radius:6px;
77
+ padding:12px 16px; cursor:pointer; transition:background .15s;
78
+ }
79
+ .session-item:hover { background:var(--bg-hover); }
80
+ .session-item .meta { font-size:12px; color:var(--text-dim); font-family:var(--mono); }
81
+ .session-detail { background:var(--bg-card); border:1px solid var(--border); border-radius:8px; padding:16px; margin-top:12px; }
82
+ .msg { margin-bottom:12px; }
83
+ .msg.user { color:var(--blue); }
84
+ .msg.assistant { color:var(--text); }
85
+ .msg pre { font-family:var(--mono); font-size:12px; white-space:pre-wrap; line-height:1.5; }
86
+
87
+ .learning-item { padding:8px 0; border-bottom:1px solid var(--border); display:flex; gap:12px; align-items:center; }
88
+ .learning-item:last-child { border:none; }
89
+ .confidence { font-size:11px; font-family:var(--mono); padding:2px 6px; border-radius:3px; }
90
+ .confidence.high { background:#10b98133; color:var(--accent); }
91
+ .confidence.mid { background:#ffd70033; color:var(--gold); }
92
+ .confidence.low { background:#6a6a7a33; color:var(--text-dim); }
93
+
94
+ .search-box {
95
+ width:100%; padding:10px 16px; background:var(--bg-card); border:1px solid var(--border);
96
+ border-radius:6px; color:var(--text); font-size:14px; margin-bottom:16px; outline:none;
97
+ }
98
+ .search-box:focus { border-color:var(--accent); }
99
+ .tab { display:none; }
100
+ .tab.active { display:block; }
101
+ .empty { color:var(--text-dim); text-align:center; padding:40px; }
102
+ </style>
103
+ </head>
104
+ <body>
105
+
106
+ <header>
107
+ <h1><span>◆</span> Franklin Panel</h1>
108
+ <div>
109
+ <span id="status" style="font-size:12px;color:var(--text-dim)">connecting</span>
110
+ <span class="dot off" id="dot"></span>
111
+ </div>
112
+ </header>
113
+
114
+ <nav>
115
+ <button class="active" data-tab="overview">Overview</button>
116
+ <button data-tab="sessions">Sessions</button>
117
+ <button data-tab="social">Social</button>
118
+ <button data-tab="learnings">Learnings</button>
119
+ </nav>
120
+
121
+ <main>
122
+ <!-- Overview -->
123
+ <div class="tab active" id="tab-overview">
124
+ <div class="grid">
125
+ <div class="card">
126
+ <h3>Wallet</h3>
127
+ <div class="big gold" id="balance">—</div>
128
+ <div class="sub" id="wallet-addr">Loading...</div>
129
+ </div>
130
+ <div class="card">
131
+ <h3>Total Spent</h3>
132
+ <div class="big green" id="total-cost">—</div>
133
+ <div class="sub" id="total-requests">— requests</div>
134
+ </div>
135
+ <div class="card">
136
+ <h3>Savings vs Opus</h3>
137
+ <div class="big green" id="savings">—</div>
138
+ <div class="sub">compared to Claude Opus pricing</div>
139
+ </div>
140
+ </div>
141
+ <div class="card" style="margin-top:16px">
142
+ <h3>Daily Cost (30 days)</h3>
143
+ <div class="daily-chart" id="daily-chart"></div>
144
+ </div>
145
+ <div class="card" style="margin-top:16px">
146
+ <h3>Model Usage</h3>
147
+ <div class="bar-chart" id="model-chart"></div>
148
+ </div>
149
+ </div>
150
+
151
+ <!-- Sessions -->
152
+ <div class="tab" id="tab-sessions">
153
+ <input class="search-box" id="session-search" placeholder="Search sessions..." />
154
+ <div class="session-list" id="session-list"></div>
155
+ <div class="session-detail" id="session-detail" style="display:none"></div>
156
+ </div>
157
+
158
+ <!-- Social -->
159
+ <div class="tab" id="tab-social">
160
+ <div class="grid" id="social-stats"></div>
161
+ <div class="card" style="margin-top:16px">
162
+ <h3>Recent Activity</h3>
163
+ <div id="social-feed" class="empty">No social activity yet</div>
164
+ </div>
165
+ </div>
166
+
167
+ <!-- Learnings -->
168
+ <div class="tab" id="tab-learnings">
169
+ <div id="learnings-list"></div>
170
+ </div>
171
+ </main>
172
+
173
+ <script>
174
+ // Tab switching
175
+ document.querySelectorAll('nav button').forEach(btn => {
176
+ btn.addEventListener('click', () => {
177
+ document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
178
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
179
+ btn.classList.add('active');
180
+ document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
181
+ });
182
+ });
183
+
184
+ // API helpers
185
+ const api = (path) => fetch('/api/' + path).then(r => r.json()).catch(() => null);
186
+
187
+ // Format currency
188
+ const usd = (n) => '$' + (n || 0).toFixed(4);
189
+ const usdBig = (n) => '$' + (n || 0).toFixed(2);
190
+
191
+ // Load overview
192
+ async function loadOverview() {
193
+ const [wallet, stats, insights] = await Promise.all([
194
+ api('wallet'), api('stats'), api('insights?days=30')
195
+ ]);
196
+
197
+ if (wallet) {
198
+ document.getElementById('balance').textContent = usdBig(wallet.balance) + ' USDC';
199
+ document.getElementById('wallet-addr').textContent = wallet.address + ' (' + wallet.chain + ')';
200
+ }
201
+
202
+ if (stats) {
203
+ document.getElementById('total-cost').textContent = usd(stats.totalCostUsd);
204
+ document.getElementById('total-requests').textContent = stats.totalRequests.toLocaleString() + ' requests';
205
+ if (stats.opusCost > 0) {
206
+ const pct = ((1 - stats.totalCostUsd / stats.opusCost) * 100).toFixed(0);
207
+ document.getElementById('savings').textContent = pct + '%';
208
+ }
209
+
210
+ // Model chart
211
+ const models = Object.entries(stats.byModel || {})
212
+ .map(([name, d]) => ({ name, cost: d.costUsd || 0 }))
213
+ .sort((a, b) => b.cost - a.cost)
214
+ .slice(0, 10);
215
+ const maxCost = Math.max(...models.map(m => m.cost), 0.001);
216
+ document.getElementById('model-chart').innerHTML = models.map(m =>
217
+ '<div class="bar-row">' +
218
+ '<span class="bar-label">' + m.name.split('/').pop() + '</span>' +
219
+ '<div class="bar-fill" style="width:' + (m.cost/maxCost*100) + '%"></div>' +
220
+ '<span class="bar-val">' + usd(m.cost) + '</span>' +
221
+ '</div>'
222
+ ).join('');
223
+ }
224
+
225
+ if (insights && insights.dailyCosts) {
226
+ const days = insights.dailyCosts.slice(-30);
227
+ const maxDay = Math.max(...days.map(d => d.cost), 0.001);
228
+ document.getElementById('daily-chart').innerHTML = days.map(d =>
229
+ '<div class="daily-bar" title="' + d.date + ': ' + usd(d.cost) + '" style="height:' + (d.cost/maxDay*100) + '%"></div>'
230
+ ).join('');
231
+ }
232
+ }
233
+
234
+ // Load sessions
235
+ async function loadSessions() {
236
+ const sessions = await api('sessions');
237
+ if (!sessions || sessions.length === 0) {
238
+ document.getElementById('session-list').innerHTML = '<div class="empty">No sessions yet</div>';
239
+ return;
240
+ }
241
+ document.getElementById('session-list').innerHTML = sessions.slice(0, 50).map(s =>
242
+ '<div class="session-item" data-id="' + s.id + '">' +
243
+ '<div>' + (s.model || 'unknown') + ' — ' + s.messageCount + ' messages</div>' +
244
+ '<div class="meta">' + new Date(s.createdAt).toLocaleString() + ' · ' + (s.workDir || '').split('/').pop() + '</div>' +
245
+ '</div>'
246
+ ).join('');
247
+
248
+ document.querySelectorAll('.session-item').forEach(el => {
249
+ el.addEventListener('click', async () => {
250
+ const id = el.dataset.id;
251
+ const history = await api('sessions/' + encodeURIComponent(id));
252
+ if (!history) return;
253
+ const detail = document.getElementById('session-detail');
254
+ detail.style.display = 'block';
255
+ detail.innerHTML = history.map(m => {
256
+ const role = m.role || 'system';
257
+ let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content).slice(0, 500);
258
+ text = text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
259
+ return '<div class="msg ' + role + '"><pre>' + role.toUpperCase() + ': ' + text + '</pre></div>';
260
+ }).join('');
261
+ });
262
+ });
263
+ }
264
+
265
+ // Session search
266
+ let searchTimeout;
267
+ document.getElementById('session-search').addEventListener('input', (e) => {
268
+ clearTimeout(searchTimeout);
269
+ searchTimeout = setTimeout(async () => {
270
+ const q = e.target.value.trim();
271
+ if (!q) { loadSessions(); return; }
272
+ const results = await api('sessions/search?q=' + encodeURIComponent(q));
273
+ if (!results || results.length === 0) {
274
+ document.getElementById('session-list').innerHTML = '<div class="empty">No results</div>';
275
+ return;
276
+ }
277
+ document.getElementById('session-list').innerHTML = results.map(r =>
278
+ '<div class="session-item">' +
279
+ '<div>' + r.snippet.replace(/</g, '&lt;') + '</div>' +
280
+ '<div class="meta">' + r.sessionId + ' · score: ' + r.score.toFixed(2) + '</div>' +
281
+ '</div>'
282
+ ).join('');
283
+ }, 300);
284
+ });
285
+
286
+ // Load social
287
+ async function loadSocial() {
288
+ const social = await api('social');
289
+ if (!social) { return; }
290
+ document.getElementById('social-stats').innerHTML =
291
+ '<div class="card"><h3>Posted</h3><div class="big green">' + (social.posted || 0) + '</div></div>' +
292
+ '<div class="card"><h3>Drafted</h3><div class="big">' + (social.drafted || 0) + '</div></div>' +
293
+ '<div class="card"><h3>Skipped</h3><div class="big">' + (social.skipped || 0) + '</div></div>' +
294
+ '<div class="card"><h3>Total Cost</h3><div class="big gold">' + usd(social.totalCost || 0) + '</div></div>';
295
+ }
296
+
297
+ // Load learnings
298
+ async function loadLearnings() {
299
+ const learnings = await api('learnings');
300
+ if (!learnings || learnings.length === 0) {
301
+ document.getElementById('learnings-list').innerHTML = '<div class="empty">No learnings yet. Franklin learns your preferences over time.</div>';
302
+ return;
303
+ }
304
+ document.getElementById('learnings-list').innerHTML = learnings
305
+ .sort((a, b) => (b.confidence * b.times_confirmed) - (a.confidence * a.times_confirmed))
306
+ .map(l => {
307
+ const cls = l.confidence >= 0.8 ? 'high' : l.confidence >= 0.5 ? 'mid' : 'low';
308
+ return '<div class="learning-item">' +
309
+ '<span class="confidence ' + cls + '">' + (l.confidence * 100).toFixed(0) + '%</span>' +
310
+ '<span>' + l.learning + '</span>' +
311
+ '<span style="margin-left:auto;color:var(--text-dim);font-size:11px">×' + l.times_confirmed + '</span>' +
312
+ '</div>';
313
+ }).join('');
314
+ }
315
+
316
+ // SSE
317
+ const es = new EventSource('/api/events');
318
+ const dot = document.getElementById('dot');
319
+ const statusEl = document.getElementById('status');
320
+ es.onopen = () => { dot.className = 'dot on'; statusEl.textContent = 'live'; };
321
+ es.onerror = () => { dot.className = 'dot off'; statusEl.textContent = 'disconnected'; };
322
+ es.onmessage = (e) => {
323
+ try {
324
+ const evt = JSON.parse(e.data);
325
+ if (evt.type === 'stats.updated') loadOverview();
326
+ } catch {}
327
+ };
328
+
329
+ // Init
330
+ loadOverview();
331
+ loadSessions();
332
+ loadSocial();
333
+ loadLearnings();
334
+ // Refresh wallet balance every 30s
335
+ setInterval(() => api('wallet').then(w => {
336
+ if (w) document.getElementById('balance').textContent = usdBig(w.balance) + ' USDC';
337
+ }), 30000);
338
+ </script>
339
+ </body>
340
+ </html>`;
341
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Franklin Panel — local HTTP server.
3
+ * Serves the dashboard HTML + JSON API endpoints + SSE for real-time updates.
4
+ * Zero external dependencies — uses node:http only.
5
+ */
6
+ import http from 'node:http';
7
+ export declare function createPanelServer(port: number): http.Server;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Franklin Panel — local HTTP server.
3
+ * Serves the dashboard HTML + JSON API endpoints + SSE for real-time updates.
4
+ * Zero external dependencies — uses node:http only.
5
+ */
6
+ import http from 'node:http';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { BLOCKRUN_DIR, loadChain } from '../config.js';
10
+ import { getStatsSummary } from '../stats/tracker.js';
11
+ import { generateInsights } from '../stats/insights.js';
12
+ import { listSessions, loadSessionHistory } from '../session/storage.js';
13
+ import { searchSessions } from '../session/search.js';
14
+ import { loadLearnings } from '../learnings/store.js';
15
+ import { getStats as getSocialStats } from '../social/db.js';
16
+ import { getHTML } from './html.js';
17
+ const sseClients = new Set();
18
+ function json(res, data, status = 200) {
19
+ res.writeHead(status, {
20
+ 'Content-Type': 'application/json',
21
+ 'Access-Control-Allow-Origin': '*',
22
+ });
23
+ res.end(JSON.stringify(data));
24
+ }
25
+ function broadcast(data) {
26
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
27
+ for (const client of sseClients) {
28
+ try {
29
+ client.write(msg);
30
+ }
31
+ catch {
32
+ sseClients.delete(client);
33
+ }
34
+ }
35
+ }
36
+ export function createPanelServer(port) {
37
+ const html = getHTML();
38
+ const server = http.createServer(async (req, res) => {
39
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
40
+ const p = url.pathname;
41
+ // ─── HTML ──
42
+ if (p === '/') {
43
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
44
+ res.end(html);
45
+ return;
46
+ }
47
+ // ─── SSE ──
48
+ if (p === '/api/events') {
49
+ res.writeHead(200, {
50
+ 'Content-Type': 'text/event-stream',
51
+ 'Cache-Control': 'no-cache',
52
+ 'Connection': 'keep-alive',
53
+ 'Access-Control-Allow-Origin': '*',
54
+ });
55
+ res.write('data: {"type":"connected"}\n\n');
56
+ sseClients.add(res);
57
+ req.on('close', () => sseClients.delete(res));
58
+ return;
59
+ }
60
+ // ─── API ──
61
+ try {
62
+ if (p === '/api/stats') {
63
+ const summary = getStatsSummary();
64
+ json(res, {
65
+ totalRequests: summary.stats.totalRequests,
66
+ totalCostUsd: summary.stats.totalCostUsd,
67
+ opusCost: summary.opusCost,
68
+ saved: summary.saved,
69
+ savedPct: summary.savedPct,
70
+ avgCostPerRequest: summary.avgCostPerRequest,
71
+ period: summary.period,
72
+ byModel: summary.stats.byModel,
73
+ });
74
+ return;
75
+ }
76
+ if (p === '/api/insights') {
77
+ const days = parseInt(url.searchParams.get('days') || '30', 10);
78
+ const report = generateInsights(days);
79
+ json(res, report);
80
+ return;
81
+ }
82
+ if (p === '/api/sessions') {
83
+ const sessions = listSessions();
84
+ json(res, sessions);
85
+ return;
86
+ }
87
+ if (p.startsWith('/api/sessions/search')) {
88
+ const q = url.searchParams.get('q') || '';
89
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
90
+ const results = searchSessions(q, { limit });
91
+ json(res, results);
92
+ return;
93
+ }
94
+ if (p.startsWith('/api/sessions/')) {
95
+ const id = decodeURIComponent(p.slice('/api/sessions/'.length));
96
+ const history = loadSessionHistory(id);
97
+ json(res, history);
98
+ return;
99
+ }
100
+ if (p === '/api/wallet') {
101
+ try {
102
+ const chain = loadChain();
103
+ let address = '', balance = 0;
104
+ if (chain === 'solana') {
105
+ const { setupAgentSolanaWallet } = await import('@blockrun/llm');
106
+ const client = await setupAgentSolanaWallet({ silent: true });
107
+ address = await client.getWalletAddress();
108
+ balance = await client.getBalance();
109
+ }
110
+ else {
111
+ const { setupAgentWallet } = await import('@blockrun/llm');
112
+ const client = setupAgentWallet({ silent: true });
113
+ address = client.getWalletAddress();
114
+ balance = await client.getBalance();
115
+ }
116
+ json(res, { address, balance, chain });
117
+ }
118
+ catch {
119
+ json(res, { address: 'not set', balance: 0, chain: loadChain() });
120
+ }
121
+ return;
122
+ }
123
+ if (p === '/api/social') {
124
+ const stats = getSocialStats();
125
+ json(res, stats);
126
+ return;
127
+ }
128
+ if (p === '/api/learnings') {
129
+ const learnings = loadLearnings();
130
+ json(res, learnings);
131
+ return;
132
+ }
133
+ // 404
134
+ res.writeHead(404);
135
+ res.end('Not found');
136
+ }
137
+ catch (err) {
138
+ json(res, { error: err.message }, 500);
139
+ }
140
+ });
141
+ // Watch stats file for changes → push to SSE clients
142
+ const statsFile = path.join(BLOCKRUN_DIR, 'runcode-stats.json');
143
+ if (fs.existsSync(statsFile)) {
144
+ fs.watchFile(statsFile, { interval: 2000 }, () => {
145
+ try {
146
+ broadcast({ type: 'stats.updated' });
147
+ }
148
+ catch { /* ignore */ }
149
+ });
150
+ }
151
+ return server;
152
+ }
@@ -5,6 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
8
9
  import { BLOCKRUN_DIR } from '../config.js';
9
10
  const MAX_SESSIONS = 20; // Keep last 20 sessions
10
11
  let resolvedSessionsDir = null;
@@ -60,8 +61,9 @@ function withWritableSessionDir(action) {
60
61
  */
61
62
  export function createSessionId() {
62
63
  const now = new Date();
63
- const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
64
- return `session-${ts}`;
64
+ const ts = now.toISOString().replace(/[:.]/g, '-');
65
+ const suffix = randomUUID().slice(0, 8);
66
+ return `session-${ts}-${suffix}`;
65
67
  }
66
68
  /**
67
69
  * Save a message to the session transcript (append-only JSONL).
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Singleton browser pool for Franklin's social subsystem.
3
+ * Wraps SocialBrowser with idle-timeout lifecycle management so the
4
+ * browser stays warm across sequential social tool calls but shuts
5
+ * down automatically after 5 minutes of inactivity.
6
+ */
7
+ import { SocialBrowser } from './browser.js';
8
+ declare class BrowserPool {
9
+ private browser;
10
+ private idleTimer;
11
+ /**
12
+ * Get a ready-to-use browser instance. If one is already running,
13
+ * reset the idle timer and return it. Otherwise launch a new one.
14
+ */
15
+ getBrowser(): Promise<SocialBrowser>;
16
+ /**
17
+ * Signal that the caller is done with the browser for now.
18
+ * Starts (or resets) the idle timer. When it fires the browser
19
+ * is closed automatically.
20
+ */
21
+ releaseBrowser(): void;
22
+ /**
23
+ * Immediately close the browser and clear the idle timer.
24
+ */
25
+ closeBrowser(): Promise<void>;
26
+ private resetIdleTimer;
27
+ }
28
+ export declare const browserPool: BrowserPool;
29
+ export {};
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Singleton browser pool for Franklin's social subsystem.
3
+ * Wraps SocialBrowser with idle-timeout lifecycle management so the
4
+ * browser stays warm across sequential social tool calls but shuts
5
+ * down automatically after 5 minutes of inactivity.
6
+ */
7
+ import { SocialBrowser } from './browser.js';
8
+ const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
9
+ class BrowserPool {
10
+ browser = null;
11
+ idleTimer = null;
12
+ /**
13
+ * Get a ready-to-use browser instance. If one is already running,
14
+ * reset the idle timer and return it. Otherwise launch a new one.
15
+ */
16
+ async getBrowser() {
17
+ if (this.browser) {
18
+ this.resetIdleTimer();
19
+ return this.browser;
20
+ }
21
+ const browser = new SocialBrowser({ headless: false });
22
+ await browser.launch();
23
+ this.browser = browser;
24
+ this.resetIdleTimer();
25
+ return this.browser;
26
+ }
27
+ /**
28
+ * Signal that the caller is done with the browser for now.
29
+ * Starts (or resets) the idle timer. When it fires the browser
30
+ * is closed automatically.
31
+ */
32
+ releaseBrowser() {
33
+ this.resetIdleTimer();
34
+ }
35
+ /**
36
+ * Immediately close the browser and clear the idle timer.
37
+ */
38
+ async closeBrowser() {
39
+ if (this.idleTimer) {
40
+ clearTimeout(this.idleTimer);
41
+ this.idleTimer = null;
42
+ }
43
+ if (this.browser) {
44
+ await this.browser.close();
45
+ this.browser = null;
46
+ }
47
+ }
48
+ resetIdleTimer() {
49
+ if (this.idleTimer) {
50
+ clearTimeout(this.idleTimer);
51
+ }
52
+ this.idleTimer = setTimeout(async () => {
53
+ await this.closeBrowser();
54
+ }, IDLE_TIMEOUT);
55
+ }
56
+ }
57
+ export const browserPool = new BrowserPool();
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pre-flight checks before social tools can run.
3
+ * Validates config readiness and browser login state.
4
+ */
5
+ import type { SocialBrowser } from './browser.js';
6
+ /**
7
+ * Verify that social config is ready and the user is logged in to X.
8
+ * Returns the browser instance on success so callers can reuse it.
9
+ */
10
+ export declare function checkSocialReady(): Promise<{
11
+ ready: boolean;
12
+ reason?: string;
13
+ browser?: SocialBrowser;
14
+ }>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pre-flight checks before social tools can run.
3
+ * Validates config readiness and browser login state.
4
+ */
5
+ import { loadConfig, isConfigReady } from './config.js';
6
+ import { browserPool } from './browser-pool.js';
7
+ /**
8
+ * Verify that social config is ready and the user is logged in to X.
9
+ * Returns the browser instance on success so callers can reuse it.
10
+ */
11
+ export async function checkSocialReady() {
12
+ const cfg = loadConfig();
13
+ const configStatus = isConfigReady(cfg);
14
+ if (!configStatus.ready) {
15
+ return { ready: false, reason: configStatus.reason };
16
+ }
17
+ const browser = await browserPool.getBrowser();
18
+ await browser.open('https://x.com/home');
19
+ await browser.waitForTimeout(2500);
20
+ const tree = await browser.snapshot();
21
+ if (!tree.includes(cfg.x.login_detection)) {
22
+ browserPool.releaseBrowser();
23
+ return { ready: false, reason: 'Not logged in to X. Run: franklin social login x' };
24
+ }
25
+ return { ready: true, browser };
26
+ }
@@ -13,6 +13,7 @@
13
13
  * Every browser interaction uses argv-based Playwright calls — zero shell
14
14
  * injection surface even if the LLM emits `$(rm -rf /)` in reply text.
15
15
  */
16
+ import { SocialBrowser } from './browser.js';
16
17
  import type { SocialConfig } from './config.js';
17
18
  import type { Chain } from '../config.js';
18
19
  export interface RunOptions {
@@ -44,3 +45,10 @@ export interface CandidatePost {
44
45
  * and processes every visible candidate until the daily target is hit.
45
46
  */
46
47
  export declare function runX(opts: RunOptions): Promise<RunResult>;
48
+ /**
49
+ * Post a reply to the currently-open tweet page.
50
+ * Locates the reply textbox, types the reply (paragraphs joined with
51
+ * Enter+Enter), clicks the reply button, confirms the "Your post was sent"
52
+ * banner.
53
+ */
54
+ export declare function postReply(browser: SocialBrowser, reply: string): Promise<void>;