@blockrun/franklin 3.7.2 → 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 +69 -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/proxy/server.js +4 -4
- 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
|
@@ -33,7 +33,6 @@ function replaceHistory(target, replacement) {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* Sanitize history: fix orphaned tool results AND inject missing results.
|
|
36
|
-
* Inspired by Claude Code's yieldMissingToolResultBlocks + Hermes _sanitize_api_messages().
|
|
37
36
|
*
|
|
38
37
|
* Two problems this solves:
|
|
39
38
|
* 1. Orphaned tool_results — results without matching tool_use calls (remove them)
|
|
@@ -215,7 +214,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
215
214
|
capabilityMap.set(cap.spec.name, cap);
|
|
216
215
|
}
|
|
217
216
|
const toolDefs = config.capabilities.map((c) => c.spec);
|
|
218
|
-
const maxTurns = config.maxTurns ??
|
|
217
|
+
const maxTurns = config.maxTurns ?? 15;
|
|
219
218
|
const workDir = config.workingDir ?? process.cwd();
|
|
220
219
|
const permissions = new PermissionManager(config.permissionMode ?? 'default', config.permissionPromptFn);
|
|
221
220
|
const history = [];
|
|
@@ -329,7 +328,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
329
328
|
onAbortReady?.(() => abort.abort());
|
|
330
329
|
let loopCount = 0;
|
|
331
330
|
let recoveryAttempts = 0;
|
|
332
|
-
const MAX_RECOVERY_ATTEMPTS = 5;
|
|
331
|
+
const MAX_RECOVERY_ATTEMPTS = 5;
|
|
333
332
|
let compactFailures = 0;
|
|
334
333
|
let maxTokensOverride;
|
|
335
334
|
const turnIdleReference = lastSessionActivity;
|
|
@@ -346,7 +345,12 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
346
345
|
const turnToolCounts = new Map(); // Per-tool-name counts this turn
|
|
347
346
|
const readFileCache = new Set(); // Files already read (dedup)
|
|
348
347
|
const MAX_TOOL_CALLS_PER_TURN = 25; // Hard cap per user turn
|
|
349
|
-
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)
|
|
350
354
|
// Agent loop for this user message
|
|
351
355
|
while (loopCount < maxTurns) {
|
|
352
356
|
loopCount++;
|
|
@@ -524,17 +528,34 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
524
528
|
responseParts = result.content;
|
|
525
529
|
usage = result.usage;
|
|
526
530
|
stopReason = result.stopReason;
|
|
527
|
-
// ── 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.
|
|
528
534
|
const hasText = responseParts.some(p => p.type === 'text' && p.text?.trim());
|
|
529
535
|
const hasTools = responseParts.some(p => p.type === 'tool_use');
|
|
530
536
|
const hasThinking = responseParts.some(p => p.type === 'thinking');
|
|
531
|
-
if (!hasText && !hasTools && !hasThinking
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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;
|
|
535
551
|
}
|
|
536
|
-
|
|
537
|
-
|
|
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;
|
|
538
559
|
}
|
|
539
560
|
}
|
|
540
561
|
catch (err) {
|
|
@@ -570,7 +591,6 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
570
591
|
// ── Prompt too long recovery (reactive compaction) ──
|
|
571
592
|
// Use forceCompact instead of autoCompactIfNeeded — the API already told us
|
|
572
593
|
// the prompt is too long, so we must compact regardless of our threshold estimate.
|
|
573
|
-
// This is the key insight from Claude Code: reactive compaction must FORCE compress.
|
|
574
594
|
if (classified.category === 'context_limit' && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
|
|
575
595
|
recoveryAttempts++;
|
|
576
596
|
if (config.debug) {
|
|
@@ -656,6 +676,35 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
656
676
|
// Record usage for stats tracking (franklin stats command)
|
|
657
677
|
const costEstimate = estimateCost(resolvedModel, inputTokens, usage.outputTokens, 1);
|
|
658
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
|
+
}
|
|
659
708
|
recordSessionUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, routingTier);
|
|
660
709
|
appendAudit({
|
|
661
710
|
ts: Date.now(),
|
|
@@ -811,7 +860,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
811
860
|
}
|
|
812
861
|
// Refresh activity timestamp after tool execution
|
|
813
862
|
lastSessionActivity = Date.now();
|
|
814
|
-
// Mid-session learning extraction
|
|
863
|
+
// Mid-session learning extraction
|
|
815
864
|
// Runs in background — never blocks the conversation
|
|
816
865
|
const { estimated: currentTokens } = getAnchoredTokenCount(history);
|
|
817
866
|
maybeMidSessionExtract(history, currentTokens, turnToolCalls, sessionId, client);
|
|
@@ -839,13 +888,16 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
839
888
|
};
|
|
840
889
|
});
|
|
841
890
|
// ── Guardrail injections ──
|
|
842
|
-
// Warn about same-tool repetition
|
|
891
|
+
// Warn about same-tool repetition — escalate on every call past threshold
|
|
843
892
|
for (const [name, count] of turnToolCounts) {
|
|
844
|
-
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.`;
|
|
845
897
|
outcomeContent.push({
|
|
846
898
|
type: 'tool_result',
|
|
847
|
-
tool_use_id: `guardrail-warn-${name}`,
|
|
848
|
-
content:
|
|
899
|
+
tool_use_id: `guardrail-warn-${name}-${count}`,
|
|
900
|
+
content: escalation,
|
|
849
901
|
is_error: true,
|
|
850
902
|
});
|
|
851
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/proxy/server.js
CHANGED
|
@@ -138,7 +138,7 @@ function detectModelSwitch(parsed) {
|
|
|
138
138
|
}
|
|
139
139
|
// Default model - smart routing built-in
|
|
140
140
|
const DEFAULT_MODEL = 'blockrun/auto';
|
|
141
|
-
// Origin allowlist: requests must either have no Origin (native HTTP
|
|
141
|
+
// Origin allowlist: requests must either have no Origin (native HTTP CLI clients)
|
|
142
142
|
// or come from localhost. This prevents drive-by wallet draining by browser extensions
|
|
143
143
|
// or other cross-origin local processes.
|
|
144
144
|
function isAllowedOrigin(origin) {
|
|
@@ -258,8 +258,8 @@ export function createProxy(options) {
|
|
|
258
258
|
return;
|
|
259
259
|
}
|
|
260
260
|
// Model override logic:
|
|
261
|
-
// -
|
|
262
|
-
//
|
|
261
|
+
// - Native Anthropic-format IDs (e.g. "claude-sonnet-4-6-20250514")
|
|
262
|
+
// don't contain "/" — these MUST be replaced with currentModel.
|
|
263
263
|
// - BlockRun model IDs always contain "/" (e.g. "blockrun/auto", "nvidia/nemotron-ultra-253b")
|
|
264
264
|
// — these should be passed through as-is.
|
|
265
265
|
// - If --model CLI flag is set, always override regardless.
|
|
@@ -398,7 +398,7 @@ export function createProxy(options) {
|
|
|
398
398
|
responseHeaders[k] = v;
|
|
399
399
|
});
|
|
400
400
|
// Intercept error responses and ensure Anthropic-format errors
|
|
401
|
-
// so
|
|
401
|
+
// so upstream CLI clients don't fall back to showing a login page
|
|
402
402
|
if (response.status >= 400 && !responseHeaders['content-type']?.includes('text/event-stream')) {
|
|
403
403
|
let errorBody;
|
|
404
404
|
try {
|
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