@blockrun/franklin 3.3.0 → 3.3.2
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/README.md +216 -233
- package/dist/agent/commands.js +24 -12
- package/dist/agent/context.js +18 -1
- package/dist/agent/loop.js +48 -19
- package/dist/banner.js +40 -27
- package/dist/commands/migrate.d.ts +13 -0
- package/dist/commands/migrate.js +413 -0
- package/dist/commands/panel.d.ts +6 -0
- package/dist/commands/panel.js +29 -0
- package/dist/commands/start.js +12 -4
- package/dist/index.js +15 -0
- package/dist/mcp/client.js +9 -2
- package/dist/mcp/config.js +28 -0
- package/dist/panel/html.d.ts +5 -0
- package/dist/panel/html.js +341 -0
- package/dist/panel/server.d.ts +7 -0
- package/dist/panel/server.js +152 -0
- package/dist/session/storage.js +4 -2
- package/dist/stats/tracker.d.ts +1 -0
- package/dist/stats/tracker.js +59 -13
- package/dist/tools/bash.js +6 -1
- package/dist/tools/index.js +0 -4
- package/dist/tools/webfetch.js +19 -9
- package/dist/tools/write.js +2 -0
- package/dist/ui/app.js +73 -44
- package/dist/ui/markdown.d.ts +9 -0
- package/dist/ui/markdown.js +86 -0
- 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, '<').replace(/>/g, '>');
|
|
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, '<') + '</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
|
+
}
|
package/dist/session/storage.js
CHANGED
|
@@ -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, '-')
|
|
64
|
-
|
|
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).
|
package/dist/stats/tracker.d.ts
CHANGED
package/dist/stats/tracker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 ─────────────────────────
|
package/dist/tools/bash.js
CHANGED
|
@@ -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 —
|
|
302
|
+
output: result + `\n\n(command killed — ${reason})`,
|
|
298
303
|
isError: true,
|
|
299
304
|
});
|
|
300
305
|
return;
|
package/dist/tools/index.js
CHANGED
|
@@ -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';
|