@blockrun/franklin 3.7.0 → 3.7.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.
@@ -38,6 +38,7 @@ You are an interactive agent — not a chatbot. Use the tools available to you t
38
38
  - **Search strategy**: Glob/Grep for directed searches (known file/symbol). Use Agent for open-ended exploration that may require multiple rounds.
39
39
  - **Batch bash**: chain sequential shell commands with && in a single call. Only split when you need intermediate output.
40
40
  - **AskUser discipline**: Only use AskUser when you need explicit confirmation for a destructive action (deleting files, dropping databases). NEVER use AskUser to ask what the user wants — just answer their message directly. If the request is vague, make a reasonable assumption and proceed.
41
+ - **Greetings**: When the user sends only a greeting or filler ("hi", "hello", "hey", "ok", "thanks", "yo"), reply with ONE short plain-text sentence (e.g. "Hi — what do you want to work on?"). Do NOT call AskUser. Do NOT assume a marketing/trading/coding task. Do NOT invoke any tools.
41
42
  - Never write to /etc, /usr, ~/.ssh, ~/.aws. Don't commit secrets.`;
42
43
  }
43
44
  function getCodeStyleSection() {
@@ -14,6 +14,7 @@ import { classifyAgentError } from './error-classifier.js';
14
14
  import { SessionToolGuard } from './tool-guard.js';
15
15
  import { recordUsage } from '../stats/tracker.js';
16
16
  import { recordSessionUsage } from '../stats/session-tracker.js';
17
+ import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
17
18
  import { estimateCost, OPUS_PRICING } from '../pricing.js';
18
19
  import { maybeMidSessionExtract } from '../learnings/extractor.js';
19
20
  import { routeRequest, parseRoutingProfile } from '../router/index.js';
@@ -656,6 +657,18 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
656
657
  const costEstimate = estimateCost(resolvedModel, inputTokens, usage.outputTokens, 1);
657
658
  recordUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, 0);
658
659
  recordSessionUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, routingTier);
660
+ appendAudit({
661
+ ts: Date.now(),
662
+ sessionId,
663
+ model: resolvedModel,
664
+ inputTokens,
665
+ outputTokens: usage.outputTokens,
666
+ costUsd: costEstimate,
667
+ source: 'agent',
668
+ workDir,
669
+ prompt: extractLastUserPrompt(history),
670
+ routingTier,
671
+ });
659
672
  // Accumulate session-level totals for session meta
660
673
  sessionInputTokens += inputTokens;
661
674
  sessionOutputTokens += usage.outputTokens;
@@ -1,7 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm';
3
3
  import { loadChain, API_URLS } from '../config.js';
4
- import { flushStats } from '../stats/tracker.js';
4
+ import { flushStats, loadStats } from '../stats/tracker.js';
5
+ import { OPUS_PRICING } from '../pricing.js';
5
6
  import { loadConfig } from './config.js';
6
7
  import { printBanner } from '../banner.js';
7
8
  import { assembleInstructions } from '../agent/context.js';
@@ -114,11 +115,17 @@ export async function startCommand(options) {
114
115
  }
115
116
  printBanner(version);
116
117
  const workDir = process.cwd();
118
+ // Auto-start panel in background unless explicitly disabled.
119
+ // Binds loopback-only (wallet secrets on /api/wallet/secret — never expose on LAN).
120
+ let panelUrl;
121
+ if (process.env.FRANKLIN_PANEL_AUTOSTART !== '0') {
122
+ panelUrl = await startPanelBackground(3100);
123
+ }
117
124
  // Session info — aligned, minimal. Model + balance live in the input bar below.
118
125
  // Full wallet address is shown so the user can copy-paste it to fund the wallet.
119
126
  console.log(chalk.dim(' Wallet: ') + (walletAddress || chalk.yellow('not set')));
120
127
  console.log(chalk.dim(' Dir: ') + workDir);
121
- console.log(chalk.dim(' Dashboard: ') + chalk.cyan('franklin panel') + chalk.dim(' → http://localhost:3100'));
128
+ console.log(chalk.dim(' Dashboard: ') + (panelUrl ? chalk.cyan(panelUrl) : chalk.cyan('franklin panel') + chalk.dim(' → http://localhost:3100')));
122
129
  console.log(chalk.dim(' Help: ') + chalk.cyan('/help'));
123
130
  console.log('');
124
131
  // Balance fetcher — used at startup and after each turn
@@ -257,6 +264,7 @@ export async function startCommand(options) {
257
264
  }
258
265
  // ─── Ink UI (interactive terminal) ─────────────────────────────────────────
259
266
  async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, onBalanceReady, fetchBalance) {
267
+ const startSnapshot = snapshotStats();
260
268
  const ui = launchInkUI({
261
269
  model,
262
270
  workDir,
@@ -324,15 +332,17 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
324
332
  catch { /* extraction is best-effort */ }
325
333
  }
326
334
  await disconnectMcpServers();
327
- // Session summary — show cost and usage before goodbye
335
+ // Session summary — delta vs. snapshot at session start
328
336
  try {
329
- const { getStatsSummary } = await import('../stats/tracker.js');
330
- const { stats, saved } = getStatsSummary();
331
- if (stats.totalRequests > 0) {
332
- const cost = stats.totalCostUsd.toFixed(4);
333
- const savedStr = saved > 0.001 ? ` · saved $${saved.toFixed(2)} vs Opus` : '';
334
- const tokens = `${(stats.totalInputTokens / 1000).toFixed(0)}k in / ${(stats.totalOutputTokens / 1000).toFixed(0)}k out`;
335
- console.log(chalk.dim(`\n Session: ${stats.totalRequests} requests · $${cost} USDC${savedStr} · ${tokens}`));
337
+ const delta = statsDelta(startSnapshot);
338
+ if (delta.requests > 0) {
339
+ const cost = delta.cost.toFixed(4);
340
+ const savedStr = delta.saved > 0.001 ? ` · saved $${delta.saved.toFixed(2)} vs Opus` : '';
341
+ const tokens = `${(delta.inputTokens / 1000).toFixed(0)}k in / ${(delta.outputTokens / 1000).toFixed(0)}k out`;
342
+ console.log(chalk.dim(`\n Session: ${delta.requests} requests · $${cost} USDC${savedStr} · ${tokens}`));
343
+ }
344
+ else {
345
+ console.log(chalk.dim('\n Session: 0 requests · no spend'));
336
346
  }
337
347
  }
338
348
  catch { /* stats unavailable */ }
@@ -343,6 +353,7 @@ async function runWithBasicUI(agentConfig, model, workDir) {
343
353
  const { TerminalUI } = await import('../ui/terminal.js');
344
354
  const ui = new TerminalUI();
345
355
  ui.printWelcome(model, workDir);
356
+ const startSnapshot = snapshotStats();
346
357
  let lastTerminalPrompt = '';
347
358
  try {
348
359
  await interactiveSession(agentConfig, async () => {
@@ -390,19 +401,69 @@ async function runWithBasicUI(agentConfig, model, workDir) {
390
401
  }
391
402
  // Session summary for piped mode
392
403
  try {
393
- const { getStatsSummary } = await import('../stats/tracker.js');
394
- const { stats, saved } = getStatsSummary();
395
- if (stats.totalRequests > 0) {
396
- const cost = stats.totalCostUsd.toFixed(4);
397
- const savedStr = saved > 0.001 ? ` · saved $${saved.toFixed(2)} vs Opus` : '';
398
- const tokens = `${(stats.totalInputTokens / 1000).toFixed(0)}k in / ${(stats.totalOutputTokens / 1000).toFixed(0)}k out`;
399
- console.error(`Session: ${stats.totalRequests} requests · $${cost} USDC${savedStr} · ${tokens}`);
404
+ const delta = statsDelta(startSnapshot);
405
+ if (delta.requests > 0) {
406
+ const cost = delta.cost.toFixed(4);
407
+ const savedStr = delta.saved > 0.001 ? ` · saved $${delta.saved.toFixed(2)} vs Opus` : '';
408
+ const tokens = `${(delta.inputTokens / 1000).toFixed(0)}k in / ${(delta.outputTokens / 1000).toFixed(0)}k out`;
409
+ console.error(`Session: ${delta.requests} requests · $${cost} USDC${savedStr} · ${tokens}`);
400
410
  }
401
411
  }
402
412
  catch { /* stats unavailable */ }
403
413
  ui.printGoodbye();
404
414
  flushStats();
405
415
  }
416
+ // ─── Panel auto-start ──────────────────────────────────────────────────────
417
+ async function startPanelBackground(startPort) {
418
+ const MAX_ATTEMPTS = 20;
419
+ try {
420
+ const { createPanelServer } = await import('../panel/server.js');
421
+ return await new Promise((resolve) => {
422
+ const tryListen = (port, attempt) => {
423
+ const server = createPanelServer(port);
424
+ server.on('error', (err) => {
425
+ if (err.code === 'EADDRINUSE' && attempt < MAX_ATTEMPTS) {
426
+ tryListen(port + 1, attempt + 1);
427
+ return;
428
+ }
429
+ resolve(undefined);
430
+ });
431
+ server.listen(port, '127.0.0.1', () => {
432
+ server.unref?.();
433
+ resolve(`http://localhost:${port}`);
434
+ });
435
+ };
436
+ tryListen(startPort, 0);
437
+ });
438
+ }
439
+ catch {
440
+ return undefined;
441
+ }
442
+ }
443
+ function snapshotStats() {
444
+ try {
445
+ const s = loadStats();
446
+ return {
447
+ requests: s.totalRequests,
448
+ cost: s.totalCostUsd,
449
+ inputTokens: s.totalInputTokens,
450
+ outputTokens: s.totalOutputTokens,
451
+ };
452
+ }
453
+ catch {
454
+ return { requests: 0, cost: 0, inputTokens: 0, outputTokens: 0 };
455
+ }
456
+ }
457
+ function statsDelta(before) {
458
+ const now = loadStats();
459
+ const requests = Math.max(0, now.totalRequests - before.requests);
460
+ const cost = Math.max(0, now.totalCostUsd - before.cost);
461
+ const inputTokens = Math.max(0, now.totalInputTokens - before.inputTokens);
462
+ const outputTokens = Math.max(0, now.totalOutputTokens - before.outputTokens);
463
+ const opusCost = (inputTokens / 1_000_000) * OPUS_PRICING.input +
464
+ (outputTokens / 1_000_000) * OPUS_PRICING.output;
465
+ return { requests, cost, inputTokens, outputTokens, saved: Math.max(0, opusCost - cost) };
466
+ }
406
467
  async function handleSlashCommand(cmd, config, ui) {
407
468
  const parts = cmd.trim().split(/\s+/);
408
469
  const command = parts[0].toLowerCase();
@@ -383,6 +383,10 @@ a:hover { text-decoration:underline; }
383
383
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
384
384
  Learnings
385
385
  </button>
386
+ <button class="nav-item" data-tab="audit">
387
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><path d="M11 8v3l2 1"/></svg>
388
+ Audit Log
389
+ </button>
386
390
  </div>
387
391
 
388
392
  <div class="sidebar-footer">
@@ -559,6 +563,31 @@ a:hover { text-decoration:underline; }
559
563
  </div>
560
564
  <div id="learnings-list"></div>
561
565
  </div>
566
+
567
+ <!-- Audit Log -->
568
+ <div class="tab" id="tab-audit">
569
+ <div class="content-header">
570
+ <h2>Audit Log</h2>
571
+ <p>Every LLM call: prompt, model, tokens, cost. Where the money actually went.</p>
572
+ </div>
573
+ <div style="display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap;">
574
+ <label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-dim);cursor:pointer;">
575
+ <input type="checkbox" id="audit-paid-only" style="margin:0;" /> Paid only
576
+ </label>
577
+ <select id="audit-since" style="padding:4px 8px;background:var(--bg-card);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;">
578
+ <option value="0">All time</option>
579
+ <option value="3600000">Last hour</option>
580
+ <option value="86400000" selected>Last 24h</option>
581
+ <option value="604800000">Last 7 days</option>
582
+ <option value="2592000000">Last 30 days</option>
583
+ </select>
584
+ <input id="audit-model" placeholder="Filter by model…" style="padding:4px 8px;background:var(--bg-card);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;width:180px;" />
585
+ <input id="audit-session" placeholder="Filter by session prefix…" style="padding:4px 8px;background:var(--bg-card);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;width:180px;" />
586
+ <button id="audit-refresh" style="padding:4px 10px;background:var(--bg-card);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;cursor:pointer;">Refresh</button>
587
+ <span id="audit-summary" style="margin-left:auto;font-size:13px;color:var(--text-dim);"></span>
588
+ </div>
589
+ <div id="audit-list" style="font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;"></div>
590
+ </div>
562
591
  </div>
563
592
 
564
593
  <script>
@@ -827,6 +856,57 @@ es.onmessage = (e) => {
827
856
  try { if (JSON.parse(e.data).type === 'stats.updated') loadOverview(); } catch {}
828
857
  };
829
858
 
859
+ async function loadAudit() {
860
+ const list = document.getElementById('audit-list');
861
+ const summary = document.getElementById('audit-summary');
862
+ if (!list) return;
863
+ const params = new URLSearchParams({ limit: '300' });
864
+ if (document.getElementById('audit-paid-only').checked) params.set('paidOnly', '1');
865
+ const sinceMs = parseInt(document.getElementById('audit-since').value, 10);
866
+ if (sinceMs > 0) params.set('since', String(Date.now() - sinceMs));
867
+ const model = document.getElementById('audit-model').value.trim();
868
+ if (model) params.set('model', model);
869
+ const session = document.getElementById('audit-session').value.trim();
870
+ if (session) params.set('session', session);
871
+
872
+ list.innerHTML = '<div style="color:var(--text-dim);padding:12px;">Loading…</div>';
873
+ const data = await fetch('/api/audit?' + params.toString()).then(r => r.json()).catch(() => null);
874
+ if (!data) { list.innerHTML = '<div style="color:var(--text-dim);padding:12px;">API offline</div>'; return; }
875
+ if (!data.entries.length) {
876
+ list.innerHTML = '<div style="color:var(--text-dim);padding:12px;">No audit entries match these filters. Run franklin and make a request.</div>';
877
+ summary.textContent = '0 calls';
878
+ return;
879
+ }
880
+ summary.textContent = data.returned + ' / ' + data.total + ' calls · $' + data.totalCostUsd.toFixed(4) + ' · ' +
881
+ (data.totalInputTokens/1000).toFixed(1) + 'k in / ' + (data.totalOutputTokens/1000).toFixed(1) + 'k out';
882
+
883
+ list.innerHTML = data.entries.map(e => {
884
+ const ts = new Date(e.ts).toLocaleString('en-US', { hour12: false });
885
+ const cost = e.costUsd > 0
886
+ ? '<span style="color:#fbbf24;">$' + e.costUsd.toFixed(4) + '</span>'
887
+ : '<span style="color:#10b981;">FREE</span>';
888
+ const fb = e.fallback ? ' <span style="color:#f97316;">·fb</span>' : '';
889
+ const sid = e.sessionId ? ' <span style="color:var(--text-dim);">' + esc(e.sessionId.slice(0,8)) + '</span>' : '';
890
+ const prompt = e.prompt
891
+ ? '<div style="color:var(--text-dim);padding:2px 0 4px 16px;white-space:pre-wrap;word-break:break-word;">"' + esc(e.prompt) + '"</div>'
892
+ : '';
893
+ const dir = e.workDir ? '<div style="color:var(--text-dim);padding:0 0 0 16px;font-size:11px;">📁 ' + esc(e.workDir) + '</div>' : '';
894
+ return '<div style="padding:8px 12px;border-bottom:1px solid var(--border);">' +
895
+ '<div><span style="color:var(--text-dim);">' + ts + '</span> ' + cost + ' <span style="color:#60a5fa;">' + esc(e.model) + '</span> ' +
896
+ '<span style="color:var(--text-dim);">in=' + e.inputTokens + ' out=' + e.outputTokens + '</span> ' +
897
+ '<span style="color:var(--text-dim);">[' + esc(e.source) + ']' + fb + '</span>' + sid + '</div>' +
898
+ prompt + dir +
899
+ '</div>';
900
+ }).join('');
901
+ }
902
+
903
+ ['audit-paid-only','audit-since','audit-model','audit-session'].forEach(id => {
904
+ const el = document.getElementById(id);
905
+ if (el) el.addEventListener(el.tagName === 'INPUT' && el.type === 'text' ? 'input' : 'change', () => loadAudit());
906
+ });
907
+ document.getElementById('audit-refresh')?.addEventListener('click', loadAudit);
908
+ document.querySelector('[data-tab="audit"]')?.addEventListener('click', loadAudit);
909
+
830
910
  loadOverview();
831
911
  loadSessions();
832
912
  loadSocial();
@@ -12,6 +12,7 @@ import { generateInsights } from '../stats/insights.js';
12
12
  import { listSessions, loadSessionHistory } from '../session/storage.js';
13
13
  import { searchSessions } from '../session/search.js';
14
14
  import { loadLearnings } from '../learnings/store.js';
15
+ import { readAudit } from '../stats/audit.js';
15
16
  import { getStats as getSocialStats } from '../social/db.js';
16
17
  import { getHTML } from './html.js';
17
18
  const sseClients = new Set();
@@ -129,6 +130,37 @@ export function createPanelServer(port) {
129
130
  json(res, report);
130
131
  return;
131
132
  }
133
+ if (p === '/api/audit') {
134
+ // Per-call LLM audit log — prompt, model, tokens, cost per call.
135
+ // Supports ?limit=N&paidOnly=1&since=<ms>&session=<prefix>&model=<substr>
136
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '200', 10), 2000);
137
+ const paidOnly = url.searchParams.get('paidOnly') === '1';
138
+ const since = parseInt(url.searchParams.get('since') || '0', 10);
139
+ const sessionFilter = url.searchParams.get('session') || '';
140
+ const modelFilter = url.searchParams.get('model') || '';
141
+ let entries = readAudit();
142
+ if (since > 0)
143
+ entries = entries.filter(e => e.ts >= since);
144
+ if (paidOnly)
145
+ entries = entries.filter(e => e.costUsd > 0);
146
+ if (sessionFilter)
147
+ entries = entries.filter(e => e.sessionId?.startsWith(sessionFilter));
148
+ if (modelFilter)
149
+ entries = entries.filter(e => e.model.includes(modelFilter));
150
+ const recent = entries.slice(-limit).reverse(); // newest first
151
+ const totalCost = entries.reduce((s, e) => s + e.costUsd, 0);
152
+ const totalIn = entries.reduce((s, e) => s + e.inputTokens, 0);
153
+ const totalOut = entries.reduce((s, e) => s + e.outputTokens, 0);
154
+ json(res, {
155
+ total: entries.length,
156
+ returned: recent.length,
157
+ totalCostUsd: totalCost,
158
+ totalInputTokens: totalIn,
159
+ totalOutputTokens: totalOut,
160
+ entries: recent,
161
+ });
162
+ return;
163
+ }
132
164
  if (p === '/api/sessions') {
133
165
  const sessions = listSessions();
134
166
  json(res, sessions);
@@ -310,13 +342,18 @@ export function createPanelServer(port) {
310
342
  console.error('[panel] request error:', err.message);
311
343
  }
312
344
  });
313
- // Swallow socket errors (client disconnects, etc.) so they don't crash the process
345
+ // Swallow socket errors (client disconnects, etc.) so they don't crash the process.
346
+ // ECONNRESET / EPIPE happen every time a browser tab closes an SSE stream — pure noise.
314
347
  server.on('clientError', (err, socket) => {
315
348
  try {
316
349
  socket.destroy();
317
350
  }
318
351
  catch { /* already closed */ }
319
- console.error('[panel] client error:', err.message);
352
+ if (err.code === 'ECONNRESET' || err.code === 'EPIPE')
353
+ return;
354
+ if (process.env.FRANKLIN_PANEL_DEBUG) {
355
+ console.error('[panel] client error:', err.message);
356
+ }
320
357
  });
321
358
  // Watch stats file for changes → push to SSE clients
322
359
  const statsFile = fs.existsSync(path.join(BLOCKRUN_DIR, 'franklin-stats.json'))
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
6
6
  import { recordUsage } from '../stats/tracker.js';
7
+ import { appendAudit } from '../stats/audit.js';
7
8
  import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
8
9
  import { routeRequest, parseRoutingProfile, } from '../router/index.js';
9
10
  import { estimateCost } from '../pricing.js';
@@ -481,6 +482,16 @@ export function createProxy(options) {
481
482
  const latencyMs = Date.now() - requestStartTime;
482
483
  const cost = estimateCost(finalModel, inputTokens, outputTokens);
483
484
  recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
485
+ appendAudit({
486
+ ts: Date.now(),
487
+ model: finalModel,
488
+ inputTokens,
489
+ outputTokens,
490
+ costUsd: cost,
491
+ latencyMs,
492
+ fallback: usedFallback,
493
+ source: 'proxy',
494
+ });
484
495
  debug(options, `recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
485
496
  }
486
497
  }
@@ -510,6 +521,16 @@ export function createProxy(options) {
510
521
  const latencyMs = Date.now() - requestStartTime;
511
522
  const cost = estimateCost(finalModel, inputTokens, outputTokens);
512
523
  recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
524
+ appendAudit({
525
+ ts: Date.now(),
526
+ model: finalModel,
527
+ inputTokens,
528
+ outputTokens,
529
+ costUsd: cost,
530
+ latencyMs,
531
+ fallback: usedFallback,
532
+ source: 'proxy',
533
+ });
513
534
  debug(options, `recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
514
535
  }
515
536
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Audit log — append-only forensic record of every LLM call.
3
+ *
4
+ * Lives at ~/.blockrun/franklin-audit.jsonl. One line per call, JSONL.
5
+ * Unlike franklin-stats.json (aggregates), this file lets you answer
6
+ * "what was I actually doing when $1.50 disappeared on Apr 12?".
7
+ *
8
+ * Fields kept intentionally small (truncated prompt, no tool args) so the
9
+ * file stays readable and doesn't leak large tool outputs to disk.
10
+ */
11
+ export interface AuditEntry {
12
+ ts: number;
13
+ sessionId?: string;
14
+ model: string;
15
+ inputTokens: number;
16
+ outputTokens: number;
17
+ costUsd: number;
18
+ latencyMs?: number;
19
+ fallback?: boolean;
20
+ source: 'agent' | 'proxy' | 'subagent' | 'moa' | 'plugin';
21
+ workDir?: string;
22
+ prompt?: string;
23
+ toolCalls?: string[];
24
+ routingTier?: string;
25
+ }
26
+ export declare function appendAudit(entry: AuditEntry): void;
27
+ export declare function getAuditFilePath(): string;
28
+ export declare function readAudit(): AuditEntry[];
29
+ /** Pull the last user message from a Dialogue history, flatten, and strip newlines. */
30
+ export declare function extractLastUserPrompt(history: Array<{
31
+ role: string;
32
+ content: unknown;
33
+ }>): string | undefined;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Audit log — append-only forensic record of every LLM call.
3
+ *
4
+ * Lives at ~/.blockrun/franklin-audit.jsonl. One line per call, JSONL.
5
+ * Unlike franklin-stats.json (aggregates), this file lets you answer
6
+ * "what was I actually doing when $1.50 disappeared on Apr 12?".
7
+ *
8
+ * Fields kept intentionally small (truncated prompt, no tool args) so the
9
+ * file stays readable and doesn't leak large tool outputs to disk.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { BLOCKRUN_DIR } from '../config.js';
14
+ const AUDIT_FILE = path.join(BLOCKRUN_DIR, 'franklin-audit.jsonl');
15
+ const PROMPT_PREVIEW_CHARS = 240;
16
+ export function appendAudit(entry) {
17
+ try {
18
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
19
+ const safe = {
20
+ ...entry,
21
+ prompt: entry.prompt ? truncate(entry.prompt, PROMPT_PREVIEW_CHARS) : undefined,
22
+ };
23
+ fs.appendFileSync(AUDIT_FILE, JSON.stringify(safe) + '\n');
24
+ }
25
+ catch {
26
+ /* best-effort — never break the agent loop on audit-write failure */
27
+ }
28
+ }
29
+ export function getAuditFilePath() {
30
+ return AUDIT_FILE;
31
+ }
32
+ export function readAudit() {
33
+ try {
34
+ if (!fs.existsSync(AUDIT_FILE))
35
+ return [];
36
+ const lines = fs.readFileSync(AUDIT_FILE, 'utf-8').split('\n');
37
+ const out = [];
38
+ for (const line of lines) {
39
+ if (!line.trim())
40
+ continue;
41
+ try {
42
+ out.push(JSON.parse(line));
43
+ }
44
+ catch { /* skip malformed line */ }
45
+ }
46
+ return out;
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ }
52
+ /** Pull the last user message from a Dialogue history, flatten, and strip newlines. */
53
+ export function extractLastUserPrompt(history) {
54
+ for (let i = history.length - 1; i >= 0; i--) {
55
+ const msg = history[i];
56
+ if (msg.role !== 'user')
57
+ continue;
58
+ const text = flattenContent(msg.content);
59
+ if (!text)
60
+ continue;
61
+ return text.replace(/\s+/g, ' ').trim();
62
+ }
63
+ return undefined;
64
+ }
65
+ function flattenContent(content) {
66
+ if (typeof content === 'string')
67
+ return content;
68
+ if (!Array.isArray(content))
69
+ return '';
70
+ const parts = [];
71
+ for (const block of content) {
72
+ if (typeof block === 'string') {
73
+ parts.push(block);
74
+ }
75
+ else if (block && typeof block === 'object') {
76
+ const b = block;
77
+ // Skip tool_result blocks — they're tool output, not user intent
78
+ if (b.type === 'text' && typeof b.text === 'string')
79
+ parts.push(b.text);
80
+ }
81
+ }
82
+ return parts.join(' ');
83
+ }
84
+ function truncate(s, n) {
85
+ return s.length > n ? s.slice(0, n) + '…' : s;
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {