@blockrun/franklin 3.7.1 → 3.7.3

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 CHANGED
@@ -456,7 +456,7 @@ The chat-based social tools (`SearchX`, `PostToX`) and the batch CLI (`franklin
456
456
  - [Plugin SDK guide](docs/plugin-sdk.md) — build your own workflow vertical
457
457
  - [Changelog](CHANGELOG.md) — every release explained
458
458
  - [Roadmap](docs/ROADMAP.md) — what's coming next
459
- - [Claude Code compatibility](docs/) — use Franklin as a payment proxy
459
+ - [Proxy mode](docs/) — use Franklin as a payment proxy for Anthropic-compatible CLI agents
460
460
 
461
461
  ---
462
462
 
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
7
  import { estimateHistoryTokens, getCompactionThreshold, COMPACTION_SUMMARY_RESERVE, } from './tokens.js';
8
- /** Max files to restore after compaction (inspired by Claude Code POST_COMPACT_MAX_FILES_TO_RESTORE) */
8
+ /** Max files to restore after compaction */
9
9
  const POST_COMPACT_MAX_FILES = 5;
10
10
  /** Max tokens to spend on post-compact file restoration */
11
11
  const POST_COMPACT_TOKEN_BUDGET = 50_000;
@@ -180,7 +180,7 @@ async function compactHistory(history, model, client, debug) {
180
180
  content: 'Got it. I have the structured context from earlier work and will continue from where things left off.',
181
181
  },
182
182
  ];
183
- // Post-compact file restoration (inspired by Claude Code)
183
+ // Post-compact file restoration
184
184
  // Re-read recently modified files to restore working context that was lost
185
185
  // during compaction. This prevents the agent from needing to re-read files
186
186
  // it was actively working on.
@@ -199,8 +199,6 @@ async function compactHistory(history, model, client, debug) {
199
199
  * Restore recently modified files after compaction.
200
200
  * Extracts file paths from the compaction summary and the original messages,
201
201
  * reads the ones that still exist, and builds a context restoration prompt.
202
- *
203
- * Inspired by Claude Code's POST_COMPACT_MAX_FILES_TO_RESTORE mechanism.
204
202
  */
205
203
  function restoreRecentFiles(summaryText, compactedMessages, debug) {
206
204
  // Extract file paths from multiple sources:
@@ -5,6 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { execSync } from 'node:child_process';
8
+ import { getWalletAddress as getBaseWalletAddress } from '@blockrun/llm';
8
9
  import { loadLearnings, decayLearnings, saveLearnings, formatForPrompt, loadSkills, formatSkillsForPrompt } from '../learnings/store.js';
9
10
  // ─── System Instructions Assembly ──────────────────────────────────────────
10
11
  // Composable prompt sections — each independently maintainable and conditionally includable.
@@ -135,6 +136,22 @@ After delivering results, if a better data source exists, add one line at the en
135
136
  "Tip: run franklin social setup && franklin social login x for live X data."
136
137
  Do NOT check access before acting. Do NOT explain what you tried. Just deliver, then tip.`;
137
138
  }
139
+ function getWalletKnowledgeSection() {
140
+ return `# Wallet Storage (answer "where is my wallet" directly — no searching)
141
+ Franklin stores wallet keys in ~/.blockrun/. When the user asks about wallet location, answer from this map — do not grep or scan.
142
+
143
+ - Base / EVM wallet (the primary wallet shown in Franklin's startup banner):
144
+ Private key file: ~/.blockrun/.session
145
+ Format: 66-char hex string starting with 0x (file name intentionally looks like a session token for obscurity)
146
+ Address: derivable from the key; also available via getWalletAddress() from @blockrun/llm
147
+ - Solana wallet:
148
+ File: ~/.blockrun/solana-wallet.json (JSON with address + private_key)
149
+ - Chain selection: ~/.blockrun/.chain ("base" or "solana")
150
+ - Spending tracker: ~/.blockrun/spending.json
151
+ - Programmatic access: import { getWalletAddress, getOrCreateWallet } from '@blockrun/llm'
152
+
153
+ When the user asks about "my wallet" without qualifier, default to Base (it's the primary chain shown at launch). Only mention Solana if the chain file says solana or the user explicitly asks.`;
154
+ }
138
155
  function getToolPatternsSection() {
139
156
  return `# Tool Selection Patterns
140
157
  - **Finding files**: Glob first (by name/pattern), then Grep (by content), then Read (specific file). Don't start with Read unless you know the exact path.
@@ -183,6 +200,7 @@ export function assembleInstructions(workingDir, model) {
183
200
  getGitProtocolSection(),
184
201
  getSocialMarketingSection(),
185
202
  getMissingAccessSection(),
203
+ getWalletKnowledgeSection(),
186
204
  getToolPatternsSection(),
187
205
  getTokenEfficiencySection(),
188
206
  getVerificationSection(),
@@ -393,8 +411,56 @@ function buildEnvironmentSection(workingDir) {
393
411
  }
394
412
  // Date
395
413
  lines.push(`- Date: ${new Date().toISOString().split('T')[0]}`);
414
+ // Franklin runtime wallet info — so the agent can answer "where is my wallet"
415
+ // without grep'ing the filesystem.
416
+ const wallet = readRuntimeWallet();
417
+ if (wallet.base || wallet.solana || wallet.chain) {
418
+ lines.push('');
419
+ lines.push('# Franklin Runtime Wallet');
420
+ if (wallet.chain)
421
+ lines.push(`- Active chain: ${wallet.chain}`);
422
+ if (wallet.base)
423
+ lines.push(`- Base wallet address: ${wallet.base} (private key at ~/.blockrun/.session)`);
424
+ if (wallet.solana)
425
+ lines.push(`- Solana wallet address: ${wallet.solana} (private key at ~/.blockrun/solana-wallet.json)`);
426
+ }
396
427
  return lines.join('\n');
397
428
  }
429
+ function readRuntimeWallet() {
430
+ const home = process.env.HOME || '';
431
+ if (!home)
432
+ return {};
433
+ const blockrunDir = path.join(home, '.blockrun');
434
+ const out = {};
435
+ try {
436
+ const chainFile = path.join(blockrunDir, '.chain');
437
+ if (fs.existsSync(chainFile)) {
438
+ const chain = fs.readFileSync(chainFile, 'utf-8').trim();
439
+ if (chain)
440
+ out.chain = chain;
441
+ }
442
+ }
443
+ catch { /* ignore */ }
444
+ // Base address: derive via @blockrun/llm (handles the private key in .session)
445
+ try {
446
+ const addr = getBaseWalletAddress();
447
+ if (addr && typeof addr === 'string')
448
+ out.base = addr;
449
+ }
450
+ catch { /* SDK may not be available in all contexts — skip silently */ }
451
+ // Solana address: read from JSON
452
+ try {
453
+ const solPath = path.join(blockrunDir, 'solana-wallet.json');
454
+ if (fs.existsSync(solPath)) {
455
+ const data = JSON.parse(fs.readFileSync(solPath, 'utf-8'));
456
+ const addr = data.address || data.publicKey;
457
+ if (addr && typeof addr === 'string')
458
+ out.solana = addr;
459
+ }
460
+ }
461
+ catch { /* ignore */ }
462
+ return out;
463
+ }
398
464
  // ─── Git Context ───────────────────────────────────────────────────────────
399
465
  const GIT_TIMEOUT_MS = 5_000;
400
466
  // Max chars for git log output — long commit messages can bloat the system prompt
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Classify model/runtime errors so recovery and UX can be more consistent.
3
3
  *
4
- * Inspired by Claude Code's multi-layer error classification:
4
+ * Multi-layer classification:
5
5
  * - Separate 'overloaded' category (529) from general server errors — shorter retry budget
6
6
  * - Auth errors (401) get special handling (token refresh, not retry)
7
7
  * - EPIPE/connection reset handled as network errors (retryable)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Classify model/runtime errors so recovery and UX can be more consistent.
3
3
  *
4
- * Inspired by Claude Code's multi-layer error classification:
4
+ * Multi-layer classification:
5
5
  * - Separate 'overloaded' category (529) from general server errors — shorter retry budget
6
6
  * - Auth errors (401) get special handling (token refresh, not retry)
7
7
  * - EPIPE/connection reset handled as network errors (retryable)
@@ -89,7 +89,7 @@ export function classifyAgentError(message) {
89
89
  };
90
90
  }
91
91
  // 529 / Overloaded — separate from generic server errors
92
- // Claude Code only allows 3 retries for these (they tend to persist)
92
+ // Limited retries since these tend to persist
93
93
  if (includesAny(err, [
94
94
  '529',
95
95
  'overloaded',
@@ -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';
@@ -32,7 +33,6 @@ function replaceHistory(target, replacement) {
32
33
  }
33
34
  /**
34
35
  * Sanitize history: fix orphaned tool results AND inject missing results.
35
- * Inspired by Claude Code's yieldMissingToolResultBlocks + Hermes _sanitize_api_messages().
36
36
  *
37
37
  * Two problems this solves:
38
38
  * 1. Orphaned tool_results — results without matching tool_use calls (remove them)
@@ -214,7 +214,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
214
214
  capabilityMap.set(cap.spec.name, cap);
215
215
  }
216
216
  const toolDefs = config.capabilities.map((c) => c.spec);
217
- const maxTurns = config.maxTurns ?? 100;
217
+ const maxTurns = config.maxTurns ?? 15;
218
218
  const workDir = config.workingDir ?? process.cwd();
219
219
  const permissions = new PermissionManager(config.permissionMode ?? 'default', config.permissionPromptFn);
220
220
  const history = [];
@@ -328,7 +328,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
328
328
  onAbortReady?.(() => abort.abort());
329
329
  let loopCount = 0;
330
330
  let recoveryAttempts = 0;
331
- const MAX_RECOVERY_ATTEMPTS = 5; // Up from 3 — Claude Code uses 10, we split the difference
331
+ const MAX_RECOVERY_ATTEMPTS = 5;
332
332
  let compactFailures = 0;
333
333
  let maxTokensOverride;
334
334
  const turnIdleReference = lastSessionActivity;
@@ -345,7 +345,12 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
345
345
  const turnToolCounts = new Map(); // Per-tool-name counts this turn
346
346
  const readFileCache = new Set(); // Files already read (dedup)
347
347
  const MAX_TOOL_CALLS_PER_TURN = 25; // Hard cap per user turn
348
- const SAME_TOOL_WARN_THRESHOLD = 5; // Warn after N calls to same tool
348
+ const SAME_TOOL_WARN_THRESHOLD = 3; // Warn after N calls to same tool (lowered from 5 — search loops were wasting turns)
349
+ // ── No-progress guardrail: kill infinite tiny-response loops ──
350
+ let consecutiveTinyResponses = 0; // Count of consecutive calls with <10 output tokens
351
+ const MAX_TINY_RESPONSES = 2; // Break after N tiny responses — if 2 calls return near-empty, something is wrong
352
+ let turnSpend = 0; // Cost spent this user turn (USD)
353
+ const MAX_TURN_SPEND_USD = 0.25; // Hard circuit breaker per user message (lowered — user wallets are real money)
349
354
  // Agent loop for this user message
350
355
  while (loopCount < maxTurns) {
351
356
  loopCount++;
@@ -523,17 +528,34 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
523
528
  responseParts = result.content;
524
529
  usage = result.usage;
525
530
  stopReason = result.stopReason;
526
- // ── Empty response recovery (inspired by Hermes _empty_content_retries) ──
531
+ // ── Empty response recovery ──
532
+ // If the model returns nothing, DON'T just retry the same model with the same input.
533
+ // That's deterministic waste. Instead: switch to a different model — then give up and tell the user.
527
534
  const hasText = responseParts.some(p => p.type === 'text' && p.text?.trim());
528
535
  const hasTools = responseParts.some(p => p.type === 'tool_use');
529
536
  const hasThinking = responseParts.some(p => p.type === 'thinking');
530
- if (!hasText && !hasTools && !hasThinking && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
531
- recoveryAttempts++;
532
- if (config.debug) {
533
- console.error(`[franklin] Empty response retrying (${recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS})`);
537
+ if (!hasText && !hasTools && !hasThinking) {
538
+ const EMPTY_FALLBACK_MODELS = ['nvidia/qwen3-coder-480b', 'nvidia/nemotron-ultra-253b', 'zai/glm-5.1'];
539
+ const nextModel = EMPTY_FALLBACK_MODELS.find(m => m !== config.model && !turnFailedModels.has(m));
540
+ if (nextModel && recoveryAttempts < 2) {
541
+ recoveryAttempts++;
542
+ turnFailedModels.add(config.model);
543
+ const oldModel = config.model;
544
+ config.model = nextModel;
545
+ config.onModelChange?.(nextModel, 'system');
546
+ if (config.debug) {
547
+ console.error(`[franklin] ${oldModel} returned empty — switching to ${nextModel}`);
548
+ }
549
+ onEvent({ kind: 'text_delta', text: `\n*${oldModel} returned empty — switching to ${nextModel}*\n` });
550
+ continue;
534
551
  }
535
- onEvent({ kind: 'text_delta', text: `\n*Empty responseretrying (${recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS})...*\n` });
536
- continue;
552
+ // No fallback available OR already tried 2 models give up, tell the user.
553
+ onEvent({
554
+ kind: 'text_delta',
555
+ text: `\n\n⚠️ The model returned an empty response and fallback models didn't help. This usually means the model is rate-limited or confused. Try rephrasing your question or switching model with \`/model\`.\n`,
556
+ });
557
+ onEvent({ kind: 'turn_done', reason: 'no_progress' });
558
+ break;
537
559
  }
538
560
  }
539
561
  catch (err) {
@@ -569,7 +591,6 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
569
591
  // ── Prompt too long recovery (reactive compaction) ──
570
592
  // Use forceCompact instead of autoCompactIfNeeded — the API already told us
571
593
  // the prompt is too long, so we must compact regardless of our threshold estimate.
572
- // This is the key insight from Claude Code: reactive compaction must FORCE compress.
573
594
  if (classified.category === 'context_limit' && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
574
595
  recoveryAttempts++;
575
596
  if (config.debug) {
@@ -655,7 +676,48 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
655
676
  // Record usage for stats tracking (franklin stats command)
656
677
  const costEstimate = estimateCost(resolvedModel, inputTokens, usage.outputTokens, 1);
657
678
  recordUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, 0);
679
+ // ── Circuit breakers: prevent infinite-loop wallet drain ──
680
+ turnSpend += costEstimate;
681
+ if (turnSpend > MAX_TURN_SPEND_USD) {
682
+ onEvent({
683
+ kind: 'text_delta',
684
+ text: `\n\n⚠️ Turn spend limit reached ($${turnSpend.toFixed(3)} > $${MAX_TURN_SPEND_USD}). Stopping to protect your wallet. Try again with a clearer prompt or a different model.\n`,
685
+ });
686
+ onEvent({ kind: 'turn_done', reason: 'budget' });
687
+ break;
688
+ }
689
+ // Count a response as "no progress" only if it made no meaningful output:
690
+ // no tool call, and no text content longer than a few chars. A short but
691
+ // legitimate response (e.g. "done" or a compact tool_use) resets the counter.
692
+ const madeProgress = responseParts.some(p => p.type === 'tool_use') ||
693
+ responseParts.some(p => p.type === 'text' && (p.text?.trim().length ?? 0) > 3);
694
+ if (!madeProgress) {
695
+ consecutiveTinyResponses++;
696
+ if (consecutiveTinyResponses >= MAX_TINY_RESPONSES) {
697
+ onEvent({
698
+ kind: 'text_delta',
699
+ text: `\n\n⚠️ Model returned ${consecutiveTinyResponses} non-productive responses in a row (${resolvedModel} may be rate-limited or confused). Stopping to save tokens. Try a different model with \`/model\` or rephrase your message.\n`,
700
+ });
701
+ onEvent({ kind: 'turn_done', reason: 'no_progress' });
702
+ break;
703
+ }
704
+ }
705
+ else {
706
+ consecutiveTinyResponses = 0;
707
+ }
658
708
  recordSessionUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, routingTier);
709
+ appendAudit({
710
+ ts: Date.now(),
711
+ sessionId,
712
+ model: resolvedModel,
713
+ inputTokens,
714
+ outputTokens: usage.outputTokens,
715
+ costUsd: costEstimate,
716
+ source: 'agent',
717
+ workDir,
718
+ prompt: extractLastUserPrompt(history),
719
+ routingTier,
720
+ });
659
721
  // Accumulate session-level totals for session meta
660
722
  sessionInputTokens += inputTokens;
661
723
  sessionOutputTokens += usage.outputTokens;
@@ -798,7 +860,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
798
860
  }
799
861
  // Refresh activity timestamp after tool execution
800
862
  lastSessionActivity = Date.now();
801
- // Mid-session learning extraction (like Claude Code's SessionMemory)
863
+ // Mid-session learning extraction
802
864
  // Runs in background — never blocks the conversation
803
865
  const { estimated: currentTokens } = getAnchoredTokenCount(history);
804
866
  maybeMidSessionExtract(history, currentTokens, turnToolCalls, sessionId, client);
@@ -826,13 +888,16 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
826
888
  };
827
889
  });
828
890
  // ── Guardrail injections ──
829
- // Warn about same-tool repetition
891
+ // Warn about same-tool repetition — escalate on every call past threshold
830
892
  for (const [name, count] of turnToolCounts) {
831
- if (count === SAME_TOOL_WARN_THRESHOLD) {
893
+ if (count >= SAME_TOOL_WARN_THRESHOLD) {
894
+ const escalation = count === SAME_TOOL_WARN_THRESHOLD
895
+ ? `[SYSTEM] You have called ${name} ${count} times this turn. Stop and present your results now. Do not make more ${name} calls.`
896
+ : `[SYSTEM] STOP. You have now called ${name} ${count} times — more searching is not producing new information. Answer the user with what you already have. If the answer truly requires a different approach, use a DIFFERENT tool or ask the user.`;
832
897
  outcomeContent.push({
833
898
  type: 'tool_result',
834
- tool_use_id: `guardrail-warn-${name}`,
835
- content: `[SYSTEM] You have called ${name} ${count} times this turn. Stop and present your results now. Do not make more ${name} calls.`,
899
+ tool_use_id: `guardrail-warn-${name}-${count}`,
900
+ content: escalation,
836
901
  is_error: true,
837
902
  });
838
903
  }
@@ -7,8 +7,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { recordFailure } from '../stats/failures.js';
9
9
  import { BLOCKRUN_DIR } from '../config.js';
10
- /** Persist a large tool result to disk and return a preview string.
11
- * Inspired by Claude Code's toolResultStorage.ts. */
10
+ /** Persist a large tool result to disk and return a preview string. */
12
11
  const PERSIST_THRESHOLD = 50_000;
13
12
  const PREVIEW_SIZE = 2_000;
14
13
  function persistLargeResult(sessionId, toolUseId, output) {
@@ -208,7 +207,7 @@ export class StreamingExecutor {
208
207
  }
209
208
  let result = await handler.execute(invocation.input, progressScope);
210
209
  this.guard?.afterExecute(invocation, result);
211
- // Persist large results to disk with preview (inspired by Claude Code toolResultStorage)
210
+ // Persist large results to disk with preview.
212
211
  // Instead of just truncating, save the full result to disk so it can be re-read later.
213
212
  if (result.output.length > PERSIST_THRESHOLD) {
214
213
  result = {
@@ -104,7 +104,7 @@ export interface StreamCapabilityDone {
104
104
  }
105
105
  export interface StreamTurnDone {
106
106
  kind: 'turn_done';
107
- reason: 'completed' | 'max_turns' | 'aborted' | 'error';
107
+ reason: 'completed' | 'max_turns' | 'aborted' | 'error' | 'budget' | 'no_progress';
108
108
  error?: string;
109
109
  }
110
110
  export interface StreamUsageInfo {
@@ -7,8 +7,6 @@
7
7
  *
8
8
  * If FAIL: injects feedback into conversation so the main agent can fix issues.
9
9
  * If PASS: work is considered verified.
10
- *
11
- * Inspired by Claude Code's verification agent architecture.
12
10
  */
13
11
  import type { CapabilityHandler, Dialogue } from './types.js';
14
12
  import { ModelClient } from './llm.js';
@@ -7,8 +7,6 @@
7
7
  *
8
8
  * If FAIL: injects feedback into conversation so the main agent can fix issues.
9
9
  * If PASS: work is considered verified.
10
- *
11
- * Inspired by Claude Code's verification agent architecture.
12
10
  */
13
11
  // ─── Verification System Prompt ───────────────────────────────────────────
14
12
  const VERIFICATION_PROMPT = `You are a VERIFICATION agent. Your job is NOT to confirm that code works — it is to TRY TO BREAK IT.
@@ -103,10 +103,10 @@ export async function initCommand(options) {
103
103
  }
104
104
  // ── 3. Start daemon now ──────────────────────────────────────────────────
105
105
  console.log('');
106
- console.log(chalk.bold('franklin initialized (proxy mode for Claude Code).'));
106
+ console.log(chalk.bold('franklin initialized (proxy mode).'));
107
107
  console.log(`Run ${chalk.bold('franklin daemon start')} to start the background proxy now.`);
108
- console.log(`Then just run ${chalk.bold('claude')} franklin proxy handles payments automatically.`);
108
+ console.log(`Anthropic-compatible CLI agents will route through franklin automatically.`);
109
109
  console.log('');
110
110
  console.log(chalk.dim('Or use franklin directly: franklin start'));
111
- console.log(chalk.dim('Note: Claude Code will ask you to trust the proxy URL once.'));
111
+ console.log(chalk.dim('Note: your CLI agent will ask you to trust the proxy URL once.'));
112
112
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Proxy-only mode — runs the BlockRun payment proxy for other tools (e.g. Claude Code).
3
- * The proxy translates requests and handles x402 payments so Claude Code can use any model.
2
+ * Proxy-only mode — runs the BlockRun payment proxy for Anthropic-compatible CLI agents.
3
+ * The proxy translates requests and handles x402 payments so any compatible client can use any model.
4
4
  */
5
5
  interface ProxyOptions {
6
6
  port?: string;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Proxy-only mode — runs the BlockRun payment proxy for other tools (e.g. Claude Code).
3
- * The proxy translates requests and handles x402 payments so Claude Code can use any model.
2
+ * Proxy-only mode — runs the BlockRun payment proxy for Anthropic-compatible CLI agents.
3
+ * The proxy translates requests and handles x402 payments so any compatible client can use any model.
4
4
  */
5
5
  import chalk from 'chalk';
6
6
  import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm';
@@ -91,7 +91,7 @@ function launchProxy(server, port, debug) {
91
91
  if (debug)
92
92
  console.log(chalk.dim(` Debug log: ~/.blockrun/franklin-debug.log`));
93
93
  console.log(chalk.dim(` Run 'franklin stats' to view statistics\n`));
94
- console.log('Set this in your shell to use with Claude Code:\n');
94
+ console.log('Set these in your shell to route Anthropic-compatible CLI agents through franklin:\n');
95
95
  console.log(chalk.bold(` export ANTHROPIC_BASE_URL=http://localhost:${port}/api`));
96
96
  console.log(chalk.bold(` export ANTHROPIC_AUTH_TOKEN=x402-proxy-handles-auth`));
97
97
  console.log(`\nThen run ${chalk.bold('claude')} in another terminal.`);
@@ -244,7 +244,7 @@ export async function startCommand(options) {
244
244
  debug: options.debug,
245
245
  resumeSessionId,
246
246
  };
247
- // Bootstrap learnings from Claude Code config on first run (async, non-blocking)
247
+ // Bootstrap learnings from existing CLAUDE.md on first run (async, non-blocking)
248
248
  Promise.all([
249
249
  import('../learnings/extractor.js'),
250
250
  import('../agent/llm.js'),
@@ -64,7 +64,7 @@ export async function uninitCommand() {
64
64
  else {
65
65
  console.log('');
66
66
  console.log(chalk.bold('franklin uninitialized.'));
67
- console.log(`Claude Code will use its default Anthropic API settings again.`);
67
+ console.log(`CLI agents will use their default Anthropic API settings again.`);
68
68
  console.log(`Run ${chalk.bold('franklin daemon stop')} to stop any running proxy.`);
69
69
  }
70
70
  }
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ program
53
53
  .action((sessionId, options) => startCommand({ ...options, version, resume: sessionId ?? 'picker' }));
54
54
  program
55
55
  .command('proxy')
56
- .description('Run payment proxy for Claude Code or other tools')
56
+ .description('Run payment proxy for Anthropic-compatible CLI agents')
57
57
  .option('-p, --port <port>', 'Proxy port', '8402')
58
58
  .option('-m, --model <model>', 'Default model for proxied requests')
59
59
  .option('--no-fallback', 'Disable automatic fallback to backup models')
@@ -168,7 +168,7 @@ program
168
168
  }
169
169
  program
170
170
  .command('migrate')
171
- .description('Import data from other AI tools (Claude Code, Cline, Cursor)')
171
+ .description('Import preferences and MCP servers from existing AI agent configs')
172
172
  .action(async () => {
173
173
  const { migrateCommand } = await import('./commands/migrate.js');
174
174
  await migrateCommand();
@@ -5,7 +5,7 @@
5
5
  import { ModelClient } from '../agent/llm.js';
6
6
  import type { Dialogue } from '../agent/types.js';
7
7
  /**
8
- * Scan for Claude Code configuration and bootstrap learnings from it.
8
+ * Scan for existing CLAUDE.md preference files and bootstrap learnings from them.
9
9
  * Only runs once — skips if learnings already exist.
10
10
  */
11
11
  export declare function bootstrapFromClaudeConfig(client: ModelClient): Promise<number>;
@@ -27,8 +27,5 @@ export declare function maybeExtractSkill(history: Dialogue[], turnToolCalls: nu
27
27
  * 1. Token count exceeds init threshold (first extraction) OR update threshold (subsequent)
28
28
  * 2. AND enough tool calls have happened since last extraction
29
29
  * 3. AND we haven't hit the per-session cap
30
- *
31
- * Inspired by Claude Code's SessionMemory which runs a background subagent
32
- * to extract conversation notes periodically.
33
30
  */
34
31
  export declare function maybeMidSessionExtract(history: Dialogue[], estimatedTokens: number, totalToolCalls: number, sessionId: string, client: ModelClient): void;
@@ -107,7 +107,7 @@ function parseExtraction(raw) {
107
107
  })),
108
108
  };
109
109
  }
110
- // ─── Onboarding: bootstrap from Claude Code config ───────────────────────
110
+ // ─── Onboarding: bootstrap from existing CLAUDE.md preferences ───────────
111
111
  const BOOTSTRAP_PROMPT = `You are analyzing a user's AI coding agent configuration file (CLAUDE.md). Extract user preferences that would help personalize a different AI agent's behavior.
112
112
 
113
113
  Analyze for:
@@ -123,12 +123,12 @@ Rules:
123
123
  - Extract EVERY explicit preference. These are user-written rules, so confidence is high (0.8-1.0).
124
124
  - Each learning should be one clear, actionable sentence.
125
125
  - Do NOT include project-specific paths or secrets.
126
- - Do NOT include things that are tool-specific to Claude Code and wouldn't apply to another agent.
126
+ - Do NOT include things that are tool-specific to a particular agent and wouldn't apply to franklin.
127
127
 
128
128
  Respond with ONLY a JSON object (no markdown fences, no commentary):
129
129
  {"learnings":[{"learning":"...","category":"language|model_preference|tool_pattern|coding_style|communication|domain|correction|workflow|other","confidence":0.9}]}`;
130
130
  /**
131
- * Scan for Claude Code configuration and bootstrap learnings from it.
131
+ * Scan for existing CLAUDE.md preference files and bootstrap learnings from them.
132
132
  * Only runs once — skips if learnings already exist.
133
133
  */
134
134
  export async function bootstrapFromClaudeConfig(client) {
@@ -136,7 +136,7 @@ export async function bootstrapFromClaudeConfig(client) {
136
136
  const existing = loadLearnings();
137
137
  if (existing.length > 0)
138
138
  return 0;
139
- // Scan for Claude Code config files
139
+ // Scan for CLAUDE.md preference files
140
140
  const configPaths = [
141
141
  path.join(os.homedir(), '.claude', 'CLAUDE.md'),
142
142
  path.join(process.cwd(), 'CLAUDE.md'),
@@ -377,9 +377,6 @@ const MID_SESSION_MAX_EXTRACTIONS = 3;
377
377
  * 1. Token count exceeds init threshold (first extraction) OR update threshold (subsequent)
378
378
  * 2. AND enough tool calls have happened since last extraction
379
379
  * 3. AND we haven't hit the per-session cap
380
- *
381
- * Inspired by Claude Code's SessionMemory which runs a background subagent
382
- * to extract conversation notes periodically.
383
380
  */
384
381
  export function maybeMidSessionExtract(history, estimatedTokens, totalToolCalls, sessionId, client) {
385
382
  // Cap reached — stop extracting
@@ -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';
@@ -137,7 +138,7 @@ function detectModelSwitch(parsed) {
137
138
  }
138
139
  // Default model - smart routing built-in
139
140
  const DEFAULT_MODEL = 'blockrun/auto';
140
- // Origin allowlist: requests must either have no Origin (native HTTP like Claude Code CLI)
141
+ // Origin allowlist: requests must either have no Origin (native HTTP CLI clients)
141
142
  // or come from localhost. This prevents drive-by wallet draining by browser extensions
142
143
  // or other cross-origin local processes.
143
144
  function isAllowedOrigin(origin) {
@@ -257,8 +258,8 @@ export function createProxy(options) {
257
258
  return;
258
259
  }
259
260
  // Model override logic:
260
- // - Claude Code sends native Anthropic IDs (e.g. "claude-sonnet-4-6-20250514")
261
- // which don't contain "/" — these MUST be replaced with currentModel.
261
+ // - Native Anthropic-format IDs (e.g. "claude-sonnet-4-6-20250514")
262
+ // don't contain "/" — these MUST be replaced with currentModel.
262
263
  // - BlockRun model IDs always contain "/" (e.g. "blockrun/auto", "nvidia/nemotron-ultra-253b")
263
264
  // — these should be passed through as-is.
264
265
  // - If --model CLI flag is set, always override regardless.
@@ -397,7 +398,7 @@ export function createProxy(options) {
397
398
  responseHeaders[k] = v;
398
399
  });
399
400
  // Intercept error responses and ensure Anthropic-format errors
400
- // so Claude Code doesn't fall back to showing a login page
401
+ // so upstream CLI clients don't fall back to showing a login page
401
402
  if (response.status >= 400 && !responseHeaders['content-type']?.includes('text/event-stream')) {
402
403
  let errorBody;
403
404
  try {
@@ -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
+ }
@@ -2,7 +2,6 @@
2
2
  * Session insights engine.
3
3
  *
4
4
  * Rich usage analytics from the stats tracker history.
5
- * Inspired by hermes-agent's `agent/insights.py` and Claude Code's /insights.
6
5
  *
7
6
  * Provides:
8
7
  * - Per-model cost and request breakdown
@@ -2,7 +2,6 @@
2
2
  * Session insights engine.
3
3
  *
4
4
  * Rich usage analytics from the stats tracker history.
5
- * Inspired by hermes-agent's `agent/insights.py` and Claude Code's /insights.
6
5
  *
7
6
  * Provides:
8
7
  * - Per-model cost and request breakdown
@@ -416,8 +416,7 @@ function executeCommand(command, timeoutMs, ctx) {
416
416
  }
417
417
  /**
418
418
  * Detect if a bash command is read-only (safe to run concurrently).
419
- * Inspired by Claude Code's isSearchOrReadBashCommand analyzes command segments
420
- * to determine if ALL operations are read-only.
419
+ * Analyzes command segments to determine if ALL operations are read-only.
421
420
  */
422
421
  const READ_ONLY_COMMANDS = new Set([
423
422
  'ls', 'cat', 'head', 'tail', 'wc', 'du', 'df', 'file', 'stat', 'tree',
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { partiallyReadFiles, fileReadTracker, invalidateFileCache } from './read.js';
7
7
  /**
8
8
  * Normalize curly/smart quotes to straight quotes.
9
- * Claude Code does this to handle API-sanitized strings and editor paste artifacts.
9
+ * Handles API-sanitized strings and editor paste artifacts.
10
10
  */
11
11
  function normalizeQuotes(str) {
12
12
  return str
@@ -106,7 +106,7 @@ async function execute(input, ctx) {
106
106
  }
107
107
  });
108
108
  withMtime.sort((a, b) => b.mtime - a.mtime);
109
- // Convert to relative paths to save tokens (same as Claude Code)
109
+ // Convert to relative paths to save tokens
110
110
  const sorted = withMtime.map(f => {
111
111
  const rel = path.relative(ctx.workingDir, f.path);
112
112
  return rel.startsWith('..') ? f.path : rel;
@@ -86,7 +86,7 @@ function runRipgrep(opts, searchPath, mode, limit, cwd) {
86
86
  const offset = opts.offset ?? 0;
87
87
  const sliced = offset > 0 ? lines.slice(offset) : lines;
88
88
  const limited = limit > 0 ? sliced.slice(0, limit) : sliced;
89
- // Convert absolute paths to relative paths to save tokens (same as Claude Code)
89
+ // Convert absolute paths to relative paths to save tokens
90
90
  const relativized = limited.map(line => {
91
91
  // Lines: /abs/path or /abs/path:rest (content mode)
92
92
  const colonIdx = line.indexOf(':');
package/dist/ui/app.js CHANGED
@@ -43,7 +43,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
43
43
  const [completedTools, setCompletedTools] = useState([]);
44
44
  // Last completed tool — shown in dynamic area so it can be expanded/collapsed with Tab
45
45
  const [expandableTool, setExpandableTool] = useState(null);
46
- // Full responses committed to Static immediately — goes into terminal scrollback like Claude Code
46
+ // Full responses committed to Static immediately — goes into terminal scrollback
47
47
  const [committedResponses, setCommittedResponses] = useState([]);
48
48
  // Short preview of latest response shown in dynamic area (last ~5 lines, cleared on next turn)
49
49
  const [responsePreview, setResponsePreview] = useState('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.7.1",
3
+ "version": "3.7.3",
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": {