@blockrun/franklin 3.15.78 → 3.15.79

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.
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import chalk from 'chalk';
6
6
  import { clearStats, getStatsSummary } from '../stats/tracker.js';
7
+ import { summarizeSdkSettlements } from '../stats/cost-log.js';
7
8
  export function statsCommand(options) {
8
9
  if (options.clear) {
9
10
  clearStats();
@@ -11,6 +12,27 @@ export function statsCommand(options) {
11
12
  return;
12
13
  }
13
14
  const { stats, opusCost, saved, savedPct, avgCostPerRequest, period } = getStatsSummary();
15
+ // SDK ledger reconciliation. `franklin-stats.json` only captures requests
16
+ // that flowed through Franklin's `recordUsage()` paths (main agent loop +
17
+ // proxy). Helper LLM calls and SDK-internal probes settle x402 payments
18
+ // through `~/.blockrun/cost_log.jsonl` (SDK-owned) — adding it here so
19
+ // the user sees the wire-level total alongside Franklin's recorded one.
20
+ // The gap between the two = recording instrumentation that's still
21
+ // missing from helper paths (analyzeTurn, compaction, evaluator, etc.).
22
+ const sdkLedger = summarizeSdkSettlements();
23
+ const recordedTotal = stats.totalCostUsd;
24
+ const sdkTotal = sdkLedger.totalUsd;
25
+ const gap = sdkTotal - recordedTotal;
26
+ const gapPct = sdkTotal > 0 ? (gap / sdkTotal) * 100 : 0;
27
+ // Bidirectional check. Two distinct gap meanings:
28
+ // sdkTotal > recordedTotal → helper LLM calls / SDK probes settled
29
+ // on-chain but bypassed Franklin's recordUsage. The ledger is the
30
+ // wire truth; recorded total is incomplete.
31
+ // sdkTotal < recordedTotal → cost_log.jsonl was probably rotated /
32
+ // truncated since the stats started accumulating. Recorded total is
33
+ // more complete; the ledger is just the recent slice.
34
+ // Treat any gap > $0.01 OR > 5% (in either direction) as worth flagging.
35
+ const significantGap = sdkTotal > 0 && (Math.abs(gap) > 0.01 || Math.abs(gapPct) > 5);
14
36
  // JSON output for programmatic access
15
37
  if (options.json) {
16
38
  console.log(JSON.stringify({
@@ -22,6 +44,21 @@ export function statsCommand(options) {
22
44
  avgCostPerRequest,
23
45
  period,
24
46
  },
47
+ sdkLedger: {
48
+ path: sdkLedger.path,
49
+ entries: sdkLedger.count,
50
+ totalUsd: sdkTotal,
51
+ byEndpoint: sdkLedger.byEndpoint.slice(0, 10),
52
+ firstTs: sdkLedger.firstTs,
53
+ lastTs: sdkLedger.lastTs,
54
+ },
55
+ reconciliation: {
56
+ recordedUsd: recordedTotal,
57
+ sdkLedgerUsd: sdkTotal,
58
+ gapUsd: gap,
59
+ gapPct,
60
+ significantGap,
61
+ },
25
62
  }, null, 2));
26
63
  return;
27
64
  }
@@ -36,7 +73,22 @@ export function statsCommand(options) {
36
73
  // Overview
37
74
  console.log(chalk.bold('\n Overview') + chalk.gray(` (${period})\n`));
38
75
  console.log(` Requests: ${chalk.cyan(stats.totalRequests.toLocaleString())}`);
39
- console.log(` Total Cost: ${chalk.green('$' + stats.totalCostUsd.toFixed(4))}`);
76
+ console.log(` Recorded Cost: ${chalk.green('$' + stats.totalCostUsd.toFixed(4))}` +
77
+ chalk.gray(' (franklin-stats.json — main loop + proxy + tools that call recordUsage)'));
78
+ if (sdkTotal > 0) {
79
+ const ledgerColor = significantGap ? chalk.yellow : chalk.green;
80
+ console.log(` SDK Ledger: ${ledgerColor('$' + sdkTotal.toFixed(4))}` +
81
+ chalk.gray(` (cost_log.jsonl — actual x402 settlements, ${sdkLedger.count} rows)`));
82
+ if (significantGap) {
83
+ const explanation = gap > 0
84
+ ? 'helper LLM calls (analyzeTurn / compaction / evaluator / verification / subagent / MoA / etc.) settled on-chain but bypassed recordUsage. SDK ledger is the wire truth.'
85
+ : 'cost_log.jsonl looks rotated or truncated — it covers fewer rows than franklin-stats.json. Recorded total is more complete than the ledger here.';
86
+ console.log(chalk.yellow(` ⚠ Gap: $${Math.abs(gap).toFixed(4)} (${Math.abs(gapPct).toFixed(1)}%) ${gap > 0 ? '↑' : '↓'} — ${explanation}`));
87
+ }
88
+ else {
89
+ console.log(chalk.gray(` Gap: $${gap.toFixed(4)} (${gapPct.toFixed(1)}%)`));
90
+ }
91
+ }
40
92
  console.log(` Avg per Request: ${chalk.gray('$' + avgCostPerRequest.toFixed(6))}`);
41
93
  console.log(` Input Tokens: ${stats.totalInputTokens.toLocaleString()}`);
42
94
  console.log(` Output Tokens: ${stats.totalOutputTokens.toLocaleString()}`);
@@ -75,6 +127,18 @@ export function statsCommand(options) {
75
127
  else {
76
128
  console.log(chalk.gray(' Not enough data to calculate savings'));
77
129
  }
130
+ // SDK ledger breakdown — surfaces non-chat endpoints (Modal, PM, x.com,
131
+ // exa, etc.) that flow through tools and may not show up in byModel.
132
+ // Only print when the ledger has real data.
133
+ if (sdkLedger.count > 0 && sdkLedger.byEndpoint.length > 0) {
134
+ console.log(chalk.bold('\n SDK Ledger (top endpoints)\n'));
135
+ for (const e of sdkLedger.byEndpoint.slice(0, 6)) {
136
+ const pct = sdkTotal > 0 ? ((e.costUsd / sdkTotal) * 100).toFixed(1) : '0';
137
+ const display = e.endpoint.length > 40 ? e.endpoint.slice(0, 37) + '...' : e.endpoint;
138
+ console.log(` ${chalk.cyan(display)}`);
139
+ console.log(chalk.gray(` ${e.count} call${e.count === 1 ? '' : 's'} · $${e.costUsd.toFixed(4)} (${pct}%)`));
140
+ }
141
+ }
78
142
  // Recent activity (last 5 requests)
79
143
  if (stats.history.length > 0) {
80
144
  console.log(chalk.bold('\n Recent Activity\n'));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Reader for `~/.blockrun/cost_log.jsonl` — the SDK-owned ledger of every
3
+ * settled x402 payment.
4
+ *
5
+ * Franklin's own `franklin-stats.json` and `franklin-audit.jsonl` only
6
+ * capture calls that pass through specific code paths (the main agent
7
+ * loop and the proxy). Helper LLM calls (analyzeTurn, prefetchForIntent,
8
+ * compaction, evaluator, verification, MoA, subagent, learning extraction,
9
+ * etc.) all settle x402 payments through the SDK — those payments DO get
10
+ * recorded in cost_log.jsonl by `@blockrun/llm` itself, but Franklin's
11
+ * stats infra had been ignoring this file entirely.
12
+ *
13
+ * Verified 2026-05-06 against a real machine: cost_log.jsonl is written
14
+ * by the SDK with snake_case keys (`cost_usd`, `ts` in unix seconds with
15
+ * subsecond precision — Python convention) and Franklin's reads/writes
16
+ * use camelCase + ms. This module bridges the format gap so stats /
17
+ * insights / `franklin balance` can surface the wallet-truth total
18
+ * alongside the recorded total.
19
+ *
20
+ * Responsibility: read-only. We never write or trim cost_log.jsonl —
21
+ * the SDK owns it.
22
+ */
23
+ export interface SettlementRow {
24
+ /** Endpoint path that was paid for, e.g. `/v1/chat/completions`. */
25
+ endpoint: string;
26
+ /** USD settled on-chain via x402. */
27
+ costUsd: number;
28
+ /** Unix milliseconds (normalized — SDK writes seconds). */
29
+ ts: number;
30
+ }
31
+ export interface SettlementSummary {
32
+ /** Path to cost_log.jsonl (or the fallback location). */
33
+ path: string;
34
+ /** Total entries read. */
35
+ count: number;
36
+ /** Sum of `costUsd` across all rows in window. */
37
+ totalUsd: number;
38
+ /** Per-endpoint breakdown sorted by cost descending. */
39
+ byEndpoint: Array<{
40
+ endpoint: string;
41
+ count: number;
42
+ costUsd: number;
43
+ }>;
44
+ /** First and last timestamps observed in the window (unix ms), or null. */
45
+ firstTs: number | null;
46
+ lastTs: number | null;
47
+ }
48
+ interface ReadOptions {
49
+ /** Override the cost_log path (for tests). Defaults to ~/.blockrun/cost_log.jsonl. */
50
+ path?: string;
51
+ sinceMs?: number;
52
+ untilMs?: number;
53
+ }
54
+ /**
55
+ * Load + parse cost_log.jsonl. Optional time window in unix milliseconds.
56
+ * Skips malformed lines silently (the SDK's JSONL writer is well-behaved
57
+ * but we don't want a single corrupted line to nuke the whole readout).
58
+ *
59
+ * Returns an empty list if the file doesn't exist — callers should treat
60
+ * that as "no SDK ledger available" rather than an error, since the file
61
+ * is only created on the first paid call.
62
+ */
63
+ export declare function loadSdkSettlements(opts?: ReadOptions): SettlementRow[];
64
+ /** Aggregate the SDK ledger into a single summary object. */
65
+ export declare function summarizeSdkSettlements(opts?: ReadOptions): SettlementSummary;
66
+ export {};
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Reader for `~/.blockrun/cost_log.jsonl` — the SDK-owned ledger of every
3
+ * settled x402 payment.
4
+ *
5
+ * Franklin's own `franklin-stats.json` and `franklin-audit.jsonl` only
6
+ * capture calls that pass through specific code paths (the main agent
7
+ * loop and the proxy). Helper LLM calls (analyzeTurn, prefetchForIntent,
8
+ * compaction, evaluator, verification, MoA, subagent, learning extraction,
9
+ * etc.) all settle x402 payments through the SDK — those payments DO get
10
+ * recorded in cost_log.jsonl by `@blockrun/llm` itself, but Franklin's
11
+ * stats infra had been ignoring this file entirely.
12
+ *
13
+ * Verified 2026-05-06 against a real machine: cost_log.jsonl is written
14
+ * by the SDK with snake_case keys (`cost_usd`, `ts` in unix seconds with
15
+ * subsecond precision — Python convention) and Franklin's reads/writes
16
+ * use camelCase + ms. This module bridges the format gap so stats /
17
+ * insights / `franklin balance` can surface the wallet-truth total
18
+ * alongside the recorded total.
19
+ *
20
+ * Responsibility: read-only. We never write or trim cost_log.jsonl —
21
+ * the SDK owns it.
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { BLOCKRUN_DIR } from '../config.js';
26
+ function getCostLogPath() {
27
+ return path.join(BLOCKRUN_DIR, 'cost_log.jsonl');
28
+ }
29
+ /**
30
+ * Load + parse cost_log.jsonl. Optional time window in unix milliseconds.
31
+ * Skips malformed lines silently (the SDK's JSONL writer is well-behaved
32
+ * but we don't want a single corrupted line to nuke the whole readout).
33
+ *
34
+ * Returns an empty list if the file doesn't exist — callers should treat
35
+ * that as "no SDK ledger available" rather than an error, since the file
36
+ * is only created on the first paid call.
37
+ */
38
+ export function loadSdkSettlements(opts) {
39
+ const file = opts?.path ?? getCostLogPath();
40
+ if (!fs.existsSync(file))
41
+ return [];
42
+ let raw;
43
+ try {
44
+ raw = fs.readFileSync(file, 'utf-8');
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ const rows = [];
50
+ const sinceMs = opts?.sinceMs ?? 0;
51
+ const untilMs = opts?.untilMs ?? Number.POSITIVE_INFINITY;
52
+ for (const line of raw.split('\n')) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed)
55
+ continue;
56
+ let obj;
57
+ try {
58
+ obj = JSON.parse(trimmed);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ const endpoint = typeof obj.endpoint === 'string' ? obj.endpoint : '';
64
+ if (!endpoint)
65
+ continue;
66
+ // SDK writes `cost_usd`. Defensively also accept `costUsd` in case a
67
+ // future SDK release switches conventions.
68
+ const costRaw = obj.cost_usd ?? obj.costUsd;
69
+ const costUsd = typeof costRaw === 'number' && Number.isFinite(costRaw) ? costRaw : 0;
70
+ // SDK writes `ts` as unix SECONDS with subsecond precision (1773424791.43...).
71
+ // Normalize to ms so callers can compare against `Date.now()` directly.
72
+ const tsRaw = obj.ts;
73
+ if (typeof tsRaw !== 'number' || !Number.isFinite(tsRaw))
74
+ continue;
75
+ const ts = tsRaw < 1e12 ? Math.round(tsRaw * 1000) : Math.round(tsRaw);
76
+ if (ts < sinceMs || ts > untilMs)
77
+ continue;
78
+ rows.push({ endpoint, costUsd, ts });
79
+ }
80
+ return rows;
81
+ }
82
+ /** Aggregate the SDK ledger into a single summary object. */
83
+ export function summarizeSdkSettlements(opts) {
84
+ const rows = loadSdkSettlements(opts);
85
+ let totalUsd = 0;
86
+ let firstTs = null;
87
+ let lastTs = null;
88
+ const byEndpointMap = new Map();
89
+ for (const r of rows) {
90
+ totalUsd += r.costUsd;
91
+ if (firstTs === null || r.ts < firstTs)
92
+ firstTs = r.ts;
93
+ if (lastTs === null || r.ts > lastTs)
94
+ lastTs = r.ts;
95
+ const acc = byEndpointMap.get(r.endpoint) ?? { count: 0, costUsd: 0 };
96
+ acc.count += 1;
97
+ acc.costUsd += r.costUsd;
98
+ byEndpointMap.set(r.endpoint, acc);
99
+ }
100
+ const byEndpoint = Array.from(byEndpointMap.entries())
101
+ .map(([endpoint, v]) => ({ endpoint, count: v.count, costUsd: v.costUsd }))
102
+ .sort((a, b) => b.costUsd - a.costUsd);
103
+ return {
104
+ path: opts?.path ?? getCostLogPath(),
105
+ count: rows.length,
106
+ totalUsd,
107
+ byEndpoint,
108
+ firstTs,
109
+ lastTs,
110
+ };
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.78",
3
+ "version": "3.15.79",
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": {