@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 +1 -1
- package/dist/agent/compact.js +2 -4
- package/dist/agent/context.js +66 -0
- package/dist/agent/error-classifier.d.ts +1 -1
- package/dist/agent/error-classifier.js +2 -2
- package/dist/agent/loop.js +82 -17
- package/dist/agent/streaming-executor.js +2 -3
- package/dist/agent/types.d.ts +1 -1
- package/dist/agent/verification.d.ts +0 -2
- package/dist/agent/verification.js +0 -2
- package/dist/commands/init.js +3 -3
- package/dist/commands/proxy.d.ts +2 -2
- package/dist/commands/proxy.js +3 -3
- package/dist/commands/start.js +1 -1
- package/dist/commands/uninit.js +1 -1
- package/dist/index.js +2 -2
- package/dist/learnings/extractor.d.ts +1 -4
- package/dist/learnings/extractor.js +4 -7
- package/dist/panel/html.js +80 -0
- package/dist/panel/server.js +39 -2
- package/dist/proxy/server.js +25 -4
- package/dist/stats/audit.d.ts +33 -0
- package/dist/stats/audit.js +86 -0
- package/dist/stats/insights.d.ts +0 -1
- package/dist/stats/insights.js +0 -1
- package/dist/tools/bash.js +1 -2
- package/dist/tools/edit.js +1 -1
- package/dist/tools/glob.js +1 -1
- package/dist/tools/grep.js +1 -1
- package/dist/ui/app.js +1 -1
- package/package.json +1 -1
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
|
-
- [
|
|
459
|
+
- [Proxy mode](docs/) — use Franklin as a payment proxy for Anthropic-compatible CLI agents
|
|
460
460
|
|
|
461
461
|
---
|
|
462
462
|
|
package/dist/agent/compact.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
package/dist/agent/context.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
92
|
+
// Limited retries since these tend to persist
|
|
93
93
|
if (includesAny(err, [
|
|
94
94
|
'529',
|
|
95
95
|
'overloaded',
|
package/dist/agent/loop.js
CHANGED
|
@@ -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 ??
|
|
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;
|
|
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 =
|
|
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
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 = {
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -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.
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
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(`
|
|
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:
|
|
111
|
+
console.log(chalk.dim('Note: your CLI agent will ask you to trust the proxy URL once.'));
|
|
112
112
|
}
|
package/dist/commands/proxy.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Proxy-only mode — runs the BlockRun payment proxy for
|
|
3
|
-
* The proxy translates requests and handles x402 payments so
|
|
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;
|
package/dist/commands/proxy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Proxy-only mode — runs the BlockRun payment proxy for
|
|
3
|
-
* The proxy translates requests and handles x402 payments so
|
|
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
|
|
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.`);
|
package/dist/commands/start.js
CHANGED
|
@@ -244,7 +244,7 @@ export async function startCommand(options) {
|
|
|
244
244
|
debug: options.debug,
|
|
245
245
|
resumeSessionId,
|
|
246
246
|
};
|
|
247
|
-
// Bootstrap learnings from
|
|
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'),
|
package/dist/commands/uninit.js
CHANGED
|
@@ -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(`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/panel/html.js
CHANGED
|
@@ -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();
|
package/dist/panel/server.js
CHANGED
|
@@ -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
|
-
|
|
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'))
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
|
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
|
-
// -
|
|
261
|
-
//
|
|
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
|
|
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
|
+
}
|
package/dist/stats/insights.d.ts
CHANGED
package/dist/stats/insights.js
CHANGED
package/dist/tools/bash.js
CHANGED
|
@@ -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
|
-
*
|
|
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',
|
package/dist/tools/edit.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
+
* Handles API-sanitized strings and editor paste artifacts.
|
|
10
10
|
*/
|
|
11
11
|
function normalizeQuotes(str) {
|
|
12
12
|
return str
|
package/dist/tools/glob.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/tools/grep.js
CHANGED
|
@@ -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
|
|
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
|
|
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