@blockrun/franklin 3.3.0 → 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.
@@ -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).
@@ -2,6 +2,7 @@
2
2
  * Usage tracking for runcode
3
3
  * Records all requests with cost, tokens, and latency for stats display
4
4
  */
5
+ export declare function getStatsFilePath(): string;
5
6
  export interface UsageRecord {
6
7
  timestamp: number;
7
8
  model: string;
@@ -6,7 +6,47 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import os from 'node:os';
8
8
  import { OPUS_PRICING } from '../pricing.js';
9
- const STATS_FILE = path.join(os.homedir(), '.blockrun', 'runcode-stats.json');
9
+ import { BLOCKRUN_DIR } from '../config.js';
10
+ let resolvedStatsFile = null;
11
+ function preferredStatsFile() {
12
+ return path.join(BLOCKRUN_DIR, 'runcode-stats.json');
13
+ }
14
+ function fallbackStatsFile() {
15
+ return path.join(os.tmpdir(), 'runcode', 'runcode-stats.json');
16
+ }
17
+ export function getStatsFilePath() {
18
+ if (resolvedStatsFile)
19
+ return resolvedStatsFile;
20
+ for (const file of [preferredStatsFile(), fallbackStatsFile()]) {
21
+ try {
22
+ fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ resolvedStatsFile = file;
24
+ return file;
25
+ }
26
+ catch {
27
+ // Try the next candidate.
28
+ }
29
+ }
30
+ resolvedStatsFile = preferredStatsFile();
31
+ return resolvedStatsFile;
32
+ }
33
+ function withWritableStatsFile(action) {
34
+ const preferred = preferredStatsFile();
35
+ const fallback = fallbackStatsFile();
36
+ try {
37
+ action(getStatsFilePath());
38
+ }
39
+ catch (err) {
40
+ const code = err.code;
41
+ const shouldFallback = (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') &&
42
+ resolvedStatsFile === preferred;
43
+ if (!shouldFallback)
44
+ throw err;
45
+ fs.mkdirSync(path.dirname(fallback), { recursive: true });
46
+ resolvedStatsFile = fallback;
47
+ action(fallback);
48
+ }
49
+ }
10
50
  const EMPTY_STATS = {
11
51
  version: 1,
12
52
  totalRequests: 0,
@@ -19,8 +59,9 @@ const EMPTY_STATS = {
19
59
  };
20
60
  export function loadStats() {
21
61
  try {
22
- if (fs.existsSync(STATS_FILE)) {
23
- const data = JSON.parse(fs.readFileSync(STATS_FILE, 'utf-8'));
62
+ const statsFile = getStatsFilePath();
63
+ if (fs.existsSync(statsFile)) {
64
+ const data = JSON.parse(fs.readFileSync(statsFile, 'utf-8'));
24
65
  // Migration: add missing fields
25
66
  return {
26
67
  ...EMPTY_STATS,
@@ -36,10 +77,12 @@ export function loadStats() {
36
77
  }
37
78
  export function saveStats(stats) {
38
79
  try {
39
- fs.mkdirSync(path.dirname(STATS_FILE), { recursive: true });
40
- // Keep only last 1000 history records
41
- stats.history = stats.history.slice(-1000);
42
- fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2));
80
+ withWritableStatsFile((statsFile) => {
81
+ fs.mkdirSync(path.dirname(statsFile), { recursive: true });
82
+ // Keep only last 1000 history records
83
+ stats.history = stats.history.slice(-1000);
84
+ fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2));
85
+ });
43
86
  }
44
87
  catch {
45
88
  /* ignore write errors */
@@ -51,13 +94,16 @@ export function clearStats() {
51
94
  clearTimeout(flushTimer);
52
95
  flushTimer = null;
53
96
  }
54
- try {
55
- if (fs.existsSync(STATS_FILE)) {
56
- fs.unlinkSync(STATS_FILE);
97
+ resolvedStatsFile = null;
98
+ for (const statsFile of new Set([preferredStatsFile(), fallbackStatsFile()])) {
99
+ try {
100
+ if (fs.existsSync(statsFile)) {
101
+ fs.unlinkSync(statsFile);
102
+ }
103
+ }
104
+ catch {
105
+ /* ignore */
57
106
  }
58
- }
59
- catch {
60
- /* ignore */
61
107
  }
62
108
  }
63
109
  // ─── In-memory stats cache with debounced write ─────────────────────────
@@ -193,6 +193,7 @@ async function execute(input, ctx) {
193
193
  let outputBytes = 0;
194
194
  let truncated = false;
195
195
  let killed = false;
196
+ let abortedByUser = false;
196
197
  const timer = setTimeout(() => {
197
198
  killed = true;
198
199
  child.kill('SIGTERM');
@@ -206,6 +207,7 @@ async function execute(input, ctx) {
206
207
  // Handle abort signal
207
208
  const onAbort = () => {
208
209
  killed = true;
210
+ abortedByUser = true;
209
211
  child.kill('SIGTERM');
210
212
  };
211
213
  ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
@@ -293,8 +295,11 @@ async function execute(input, ctx) {
293
295
  result = `... (${omitted.toLocaleString()} chars omitted from start)\n${trimmed}`;
294
296
  }
295
297
  if (killed) {
298
+ const reason = abortedByUser
299
+ ? 'aborted by user'
300
+ : `timeout after ${timeoutMs / 1000}s. Set timeout param up to 600000ms for longer.`;
296
301
  resolve({
297
- output: result + `\n\n(command killed — timeout after ${timeoutMs / 1000}s. Set timeout param up to 600000ms for longer.)`,
302
+ output: result + `\n\n(command killed — ${reason})`,
298
303
  isError: true,
299
304
  });
300
305
  return;
@@ -13,8 +13,6 @@ import { taskCapability } from './task.js';
13
13
  import { imageGenCapability } from './imagegen.js';
14
14
  import { askUserCapability } from './askuser.js';
15
15
  import { tradingSignalCapability, tradingMarketCapability } from './trading.js';
16
- import { searchXCapability } from './searchx.js';
17
- import { postToXCapability } from './posttox.js';
18
16
  /** All capabilities available to the runcode agent (excluding sub-agent, which needs config). */
19
17
  export const allCapabilities = [
20
18
  readCapability,
@@ -30,8 +28,6 @@ export const allCapabilities = [
30
28
  askUserCapability,
31
29
  tradingSignalCapability,
32
30
  tradingMarketCapability,
33
- searchXCapability,
34
- postToXCapability,
35
31
  ];
36
32
  export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, };
37
33
  export { createSubAgentCapability } from './subagent.js';