@blockrun/franklin 3.8.7 → 3.8.9
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/dist/agent/bash-guard.js +29 -0
- package/dist/agent/loop.js +61 -2
- package/dist/agent/permissions.js +2 -2
- package/dist/agent/types.d.ts +7 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.js +251 -0
- package/dist/commands/start.d.ts +4 -0
- package/dist/commands/start.js +72 -2
- package/dist/index.js +17 -1
- package/dist/panel/html.js +111 -21
- package/dist/panel/server.js +15 -4
- package/dist/tools/activate.d.ts +29 -0
- package/dist/tools/activate.js +96 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/read.js +20 -1
- package/dist/tools/tool-categories.d.ts +22 -0
- package/dist/tools/tool-categories.js +44 -0
- package/dist/tools/trading-execute.d.ts +11 -21
- package/dist/tools/trading-execute.js +43 -130
- package/dist/tools/trading-views.d.ts +64 -0
- package/dist/tools/trading-views.js +115 -0
- package/dist/tools/trading.js +86 -7
- package/dist/tools/webhook.d.ts +18 -0
- package/dist/tools/webhook.js +185 -0
- package/dist/tools/write.js +20 -0
- package/dist/trading/data.d.ts +24 -1
- package/dist/trading/data.js +67 -102
- package/dist/trading/providers/blockrun/client.d.ts +48 -0
- package/dist/trading/providers/blockrun/client.js +253 -0
- package/dist/trading/providers/blockrun/price.d.ts +24 -0
- package/dist/trading/providers/blockrun/price.js +110 -0
- package/dist/trading/providers/coingecko/client.d.ts +20 -0
- package/dist/trading/providers/coingecko/client.js +87 -0
- package/dist/trading/providers/coingecko/markets.d.ts +3 -0
- package/dist/trading/providers/coingecko/markets.js +25 -0
- package/dist/trading/providers/coingecko/ohlcv.d.ts +3 -0
- package/dist/trading/providers/coingecko/ohlcv.js +29 -0
- package/dist/trading/providers/coingecko/price.d.ts +11 -0
- package/dist/trading/providers/coingecko/price.js +41 -0
- package/dist/trading/providers/coingecko/trending.d.ts +3 -0
- package/dist/trading/providers/coingecko/trending.js +22 -0
- package/dist/trading/providers/fetcher.d.ts +43 -0
- package/dist/trading/providers/fetcher.js +45 -0
- package/dist/trading/providers/registry.d.ts +45 -0
- package/dist/trading/providers/registry.js +82 -0
- package/dist/trading/providers/standard-models.d.ts +94 -0
- package/dist/trading/providers/standard-models.js +21 -0
- package/dist/trading/providers/telemetry.d.ts +51 -0
- package/dist/trading/providers/telemetry.js +115 -0
- package/package.json +1 -1
package/dist/agent/bash-guard.js
CHANGED
|
@@ -16,8 +16,24 @@ const DANGEROUS_PATTERNS = [
|
|
|
16
16
|
[/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+[/~]/, 'recursive delete on root/home'],
|
|
17
17
|
[/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*f/, 'forced recursive delete'],
|
|
18
18
|
[/\brm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]/, 'forced recursive delete'],
|
|
19
|
+
[/\brm\s+-[a-zA-Z]*f\s+\//, 'forced delete at filesystem root'],
|
|
19
20
|
[/\bmkfs\b/, 'format filesystem'],
|
|
20
21
|
[/\bdd\s+.*of=/, 'raw disk write'],
|
|
22
|
+
[/\btruncate\s+-s\s+0\b/, 'truncate file to zero'],
|
|
23
|
+
[/>\s*\/dev\/(sd|nvme|disk|hd)/, 'write to raw block device'],
|
|
24
|
+
// Silently overwriting with mv/cp
|
|
25
|
+
[/\bmv\s+-f\b/, 'mv -f overwrites target silently'],
|
|
26
|
+
[/\bcp\s+-[a-zA-Z]*f[a-zA-Z]*r/, 'cp -rf can overwrite directory trees silently'],
|
|
27
|
+
// Writes to system-level paths — most agents should NEVER touch these.
|
|
28
|
+
// Redirections (`>`, `>>`) or tee'ing to /etc/, /usr/, /boot/, /var/lib/ etc.
|
|
29
|
+
[/>\s*\/(etc|usr|bin|sbin|boot|lib|lib64|var\/lib|sys|proc)\//, 'write to system path'],
|
|
30
|
+
[/\btee\s+.*\s+\/(etc|usr|bin|sbin|boot|lib|lib64|var\/lib|sys|proc)\//, 'tee to system path'],
|
|
31
|
+
// Extract tar/zip at filesystem root — classic traversal foot-gun.
|
|
32
|
+
[/\btar\s+.*-C\s+\/(?!tmp|var\/tmp|home)/, 'extract archive to system path'],
|
|
33
|
+
[/\bunzip\s+.*-d\s+\/(?!tmp|var\/tmp|home)/, 'unzip to system path'],
|
|
34
|
+
// Shell-out of untrusted text
|
|
35
|
+
[/\beval\s/, 'eval executes arbitrary shell'],
|
|
36
|
+
[/\bexec\s+(bash|sh|zsh)/, 'exec replaces the shell process'],
|
|
21
37
|
// Git irreversible operations
|
|
22
38
|
[/\bgit\s+push\s+.*--force\b/, 'force push'],
|
|
23
39
|
[/\bgit\s+push\s+-f\b/, 'force push'],
|
|
@@ -25,11 +41,14 @@ const DANGEROUS_PATTERNS = [
|
|
|
25
41
|
[/\bgit\s+clean\s+-[a-zA-Z]*f/, 'git clean — deletes untracked files'],
|
|
26
42
|
[/\bgit\s+checkout\s+--\s+\./, 'discard all working changes'],
|
|
27
43
|
[/\bgit\s+branch\s+-D\b/, 'force delete branch'],
|
|
44
|
+
[/\bgit\s+filter-(repo|branch)\b/, 'history rewrite'],
|
|
28
45
|
// Database destructive
|
|
29
46
|
[/\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i, 'drop database objects'],
|
|
30
47
|
[/\bTRUNCATE\s+TABLE\b/i, 'truncate table'],
|
|
48
|
+
[/\bDELETE\s+FROM\s+\S+\s*;?\s*$/i, 'DELETE without WHERE'],
|
|
31
49
|
// System-level danger
|
|
32
50
|
[/\bchmod\s+(-R\s+)?777\b/, 'world-writable permissions'],
|
|
51
|
+
[/\bchown\s+-R\s+\S+\s+\//, 'recursive chown at root'],
|
|
33
52
|
// Pipe-to-shell: catch sudo/env prefixes and common shell variants (bash/sh/zsh/ksh/dash/fish).
|
|
34
53
|
// The optional `-e`/`-x` flags after the shell binary are intentionally allowed by \b;
|
|
35
54
|
// what we block is the routing of downloaded content into an interpreter.
|
|
@@ -38,11 +57,21 @@ const DANGEROUS_PATTERNS = [
|
|
|
38
57
|
// Command substitution of a downloader into argv — `$(curl …)` or `` `curl …` ``.
|
|
39
58
|
[/\$\(\s*(curl|wget|fetch)\b/, 'command substitution of network downloader'],
|
|
40
59
|
[/`\s*(curl|wget|fetch)\b[^`]*`/, 'backtick substitution of network downloader'],
|
|
60
|
+
// Privilege escalation wrappers to destructive ops — order matters: the
|
|
61
|
+
// specific `sudo rm` pattern is listed first so its tailored message wins.
|
|
41
62
|
[/\bsudo\s+rm\b/, 'sudo delete'],
|
|
63
|
+
[/\b(sudo|doas|su\s+-c)\s+.*\b(mv|dd|chmod|chown|mkfs|shutdown|reboot)\b/, 'privileged destructive op'],
|
|
64
|
+
// sed -i (in-place) on any system path
|
|
65
|
+
[/\bsed\s+-i(\s+'')?\s+.*\/(etc|usr|bin|sbin|boot|lib)\//, 'in-place edit of system path'],
|
|
42
66
|
// Kill/shutdown
|
|
43
67
|
[/\bkill\s+-9\s+-1\b/, 'kill all processes'],
|
|
68
|
+
[/\bkillall\s/, 'killall targets matching processes globally'],
|
|
44
69
|
[/\bshutdown\b/, 'system shutdown'],
|
|
45
70
|
[/\breboot\b/, 'system reboot'],
|
|
71
|
+
[/\bpoweroff\b/, 'system poweroff'],
|
|
72
|
+
// Cryptocurrency key exfiltration / secret exposure
|
|
73
|
+
[/\bcat\s+.*\.env(\.\w+)?\s*\|/, 'env file piped — potential secret exfiltration'],
|
|
74
|
+
[/\bcat\s+.*(\.ssh|\.gnupg)\/.*\s*\|/, 'ssh/gpg key piped — potential secret exfiltration'],
|
|
46
75
|
];
|
|
47
76
|
// ─── Safe Commands ────────────────────────────────────────────────────────
|
|
48
77
|
// If ALL segments use these commands, auto-approve.
|
package/dist/agent/loop.js
CHANGED
|
@@ -13,6 +13,8 @@ import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS, getMaxOutputT
|
|
|
13
13
|
import { classifyAgentError } from './error-classifier.js';
|
|
14
14
|
import { SessionToolGuard } from './tool-guard.js';
|
|
15
15
|
import { resetToolSessionState } from '../tools/index.js';
|
|
16
|
+
import { CORE_TOOL_NAMES, dynamicToolsEnabled } from '../tools/tool-categories.js';
|
|
17
|
+
import { createActivateToolCapability } from '../tools/activate.js';
|
|
16
18
|
import { recordUsage } from '../stats/tracker.js';
|
|
17
19
|
import { recordSessionUsage } from '../stats/session-tracker.js';
|
|
18
20
|
import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
|
|
@@ -317,11 +319,33 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
317
319
|
chain: config.chain,
|
|
318
320
|
debug: config.debug,
|
|
319
321
|
});
|
|
322
|
+
// ── Dynamic tool visibility ──
|
|
323
|
+
// Register ActivateTool before building the capability map so the agent
|
|
324
|
+
// can always reach the meta-tool. When FRANKLIN_DYNAMIC_TOOLS=0 is set,
|
|
325
|
+
// `activeTools` is seeded with every registered name — behaves as the
|
|
326
|
+
// pre-3.8.9 static registry.
|
|
320
327
|
const capabilityMap = new Map();
|
|
321
328
|
for (const cap of config.capabilities) {
|
|
322
329
|
capabilityMap.set(cap.spec.name, cap);
|
|
323
330
|
}
|
|
324
|
-
const
|
|
331
|
+
const activeTools = new Set();
|
|
332
|
+
const dynamicTools = dynamicToolsEnabled();
|
|
333
|
+
if (dynamicTools) {
|
|
334
|
+
for (const name of CORE_TOOL_NAMES) {
|
|
335
|
+
if (capabilityMap.has(name))
|
|
336
|
+
activeTools.add(name);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
for (const cap of config.capabilities)
|
|
341
|
+
activeTools.add(cap.spec.name);
|
|
342
|
+
}
|
|
343
|
+
const activateToolCap = createActivateToolCapability({ activeTools, allTools: capabilityMap });
|
|
344
|
+
capabilityMap.set(activateToolCap.spec.name, activateToolCap);
|
|
345
|
+
if (dynamicTools)
|
|
346
|
+
activeTools.add(activateToolCap.spec.name);
|
|
347
|
+
const allToolDefs = [...capabilityMap.values()].map(c => c.spec);
|
|
348
|
+
const buildCallToolDefs = () => dynamicTools ? allToolDefs.filter(t => activeTools.has(t.name)) : allToolDefs;
|
|
325
349
|
const maxTurns = config.maxTurns ?? 15;
|
|
326
350
|
const workDir = config.workingDir ?? process.cwd();
|
|
327
351
|
const permissions = new PermissionManager(config.permissionMode ?? 'default', config.permissionPromptFn);
|
|
@@ -576,6 +600,20 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
576
600
|
'4. Think step by step — show your reasoning explicitly when it adds value\n' +
|
|
577
601
|
'Prioritize correctness and thoroughness over speed.');
|
|
578
602
|
}
|
|
603
|
+
// ── Dynamic tool visibility hint ──
|
|
604
|
+
// When the core/on-demand split is active, tell every model up front
|
|
605
|
+
// that its tool list is intentionally small and that extras can be
|
|
606
|
+
// pulled via ActivateTool. Kept byte-stable across turns (no tool
|
|
607
|
+
// names inlined) so the prompt cache still holds.
|
|
608
|
+
if (dynamicTools && allToolDefs.length > activeTools.size) {
|
|
609
|
+
systemParts.push('# Tool Inventory\n' +
|
|
610
|
+
'Your current tool list is intentionally minimal. Additional tools ' +
|
|
611
|
+
'(web search, image/video/music generation, trading, content, brain ' +
|
|
612
|
+
'recall, etc.) are available on demand. Call `ActivateTool()` with ' +
|
|
613
|
+
'no arguments to see what is available, then call `ActivateTool({ ' +
|
|
614
|
+
'"names": ["<name>"] })` to enable the ones you need. Activated ' +
|
|
615
|
+
'tools become visible on the next turn.');
|
|
616
|
+
}
|
|
579
617
|
// ── Context awareness injection ──
|
|
580
618
|
// Tell the model how full its context window is so it can self-regulate.
|
|
581
619
|
// At high usage, nudge it to be concise and avoid unnecessary tool calls.
|
|
@@ -669,7 +707,10 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
669
707
|
}
|
|
670
708
|
// Build per-call tool defs, max_tokens, and system prompt
|
|
671
709
|
// (planning calls get no tools + short output + planning prompt)
|
|
672
|
-
|
|
710
|
+
// Dynamic visibility: `buildCallToolDefs()` returns only the active set
|
|
711
|
+
// (core + any the agent pulled via ActivateTool). Re-evaluated every
|
|
712
|
+
// turn so newly activated tools take effect immediately.
|
|
713
|
+
let callToolDefs = buildCallToolDefs();
|
|
673
714
|
let callMaxTokens = maxTokens;
|
|
674
715
|
let callSystemPrompt = systemPrompt;
|
|
675
716
|
if (planActive && loopCount === 1) {
|
|
@@ -918,6 +959,24 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
918
959
|
const opusCost = (inputTokens / 1_000_000) * OPUS_PRICING.input
|
|
919
960
|
+ (usage.outputTokens / 1_000_000) * OPUS_PRICING.output;
|
|
920
961
|
sessionSavedVsOpus += Math.max(0, opusCost - costEstimate);
|
|
962
|
+
// ── Max-spend guard ──
|
|
963
|
+
// Session-level cost ceiling. Cron/daily drivers pass this to bound a
|
|
964
|
+
// single run ("spend at most $0.50 for today's digest"); interactive
|
|
965
|
+
// users can pass it to feel safe walking away. Hits as soon as accumulated
|
|
966
|
+
// cost crosses the cap — the last call that tipped us over still runs,
|
|
967
|
+
// but no further API calls are made.
|
|
968
|
+
const maxSpend = config.maxSpendUsd;
|
|
969
|
+
if (typeof maxSpend === 'number' && Number.isFinite(maxSpend) && maxSpend > 0 &&
|
|
970
|
+
sessionCostUsd >= maxSpend) {
|
|
971
|
+
onEvent({
|
|
972
|
+
kind: 'text_delta',
|
|
973
|
+
text: `\n\n_Max-spend reached: $${sessionCostUsd.toFixed(4)} ≥ cap $${maxSpend.toFixed(2)}. ` +
|
|
974
|
+
`Stopping session — further calls would exceed the budget._\n`,
|
|
975
|
+
});
|
|
976
|
+
persistSessionMeta();
|
|
977
|
+
onEvent({ kind: 'turn_done', reason: 'budget' });
|
|
978
|
+
return history;
|
|
979
|
+
}
|
|
921
980
|
// ── Max output tokens recovery ──
|
|
922
981
|
if (stopReason === 'max_tokens' && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
|
|
923
982
|
recoveryAttempts++;
|
|
@@ -33,10 +33,10 @@ function isCommonDevCommand(cmd) {
|
|
|
33
33
|
return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
|
|
34
34
|
}
|
|
35
35
|
// ─── Default Rules ─────────────────────────────────────────────────────────
|
|
36
|
-
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
|
|
36
|
+
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
|
|
37
37
|
const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
|
|
38
38
|
const DEFAULT_RULES = {
|
|
39
|
-
allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'],
|
|
39
|
+
allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'],
|
|
40
40
|
deny: [],
|
|
41
41
|
ask: ['Write', 'Edit', 'Bash', 'Agent', 'PostToX'],
|
|
42
42
|
};
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -155,4 +155,11 @@ export interface AgentConfig {
|
|
|
155
155
|
* unset. Format: "<driver>:<owner-or-chat-id>", e.g. "telegram:12345".
|
|
156
156
|
*/
|
|
157
157
|
sessionChannel?: string;
|
|
158
|
+
/**
|
|
159
|
+
* Hard cap on total USD spend for this session. When accumulated API cost
|
|
160
|
+
* crosses the cap, the loop stops with `reason: 'budget'`. Zero/negative
|
|
161
|
+
* values disable the cap. Primary use case: cron/daily drivers that must
|
|
162
|
+
* bound a single run to keep autonomous execution inside a known envelope.
|
|
163
|
+
*/
|
|
164
|
+
maxSpendUsd?: number;
|
|
158
165
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `franklin doctor` — one-command health check.
|
|
3
|
+
*
|
|
4
|
+
* The single highest-leverage onboarding improvement: most early failures
|
|
5
|
+
* are environmental (Node too old, no wallet, wrong chain, unreachable
|
|
6
|
+
* gateway, malformed MCP config). `franklin doctor` pokes each of those in
|
|
7
|
+
* sequence, prints a verdict per check, and exits non-zero if anything is
|
|
8
|
+
* broken so CI scripts can gate on it.
|
|
9
|
+
*
|
|
10
|
+
* Human-readable by default. Pass `--json` for machine-parseable output
|
|
11
|
+
* (useful for the ink REPL `/doctor` or external monitoring).
|
|
12
|
+
*/
|
|
13
|
+
export declare function doctorCommand(opts?: {
|
|
14
|
+
json?: boolean;
|
|
15
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `franklin doctor` — one-command health check.
|
|
3
|
+
*
|
|
4
|
+
* The single highest-leverage onboarding improvement: most early failures
|
|
5
|
+
* are environmental (Node too old, no wallet, wrong chain, unreachable
|
|
6
|
+
* gateway, malformed MCP config). `franklin doctor` pokes each of those in
|
|
7
|
+
* sequence, prints a verdict per check, and exits non-zero if anything is
|
|
8
|
+
* broken so CI scripts can gate on it.
|
|
9
|
+
*
|
|
10
|
+
* Human-readable by default. Pass `--json` for machine-parseable output
|
|
11
|
+
* (useful for the ink REPL `/doctor` or external monitoring).
|
|
12
|
+
*/
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { setupAgentWallet, setupAgentSolanaWallet, } from '@blockrun/llm';
|
|
18
|
+
import { loadChain, API_URLS, VERSION, BLOCKRUN_DIR } from '../config.js';
|
|
19
|
+
import { isTelemetryEnabled, readAllRecords } from '../telemetry/store.js';
|
|
20
|
+
async function runChecks() {
|
|
21
|
+
const out = [];
|
|
22
|
+
// ── 1. Runtime ────────────────────────────────────────────────────
|
|
23
|
+
const nodeVer = process.versions.node;
|
|
24
|
+
const nodeMajor = parseInt(nodeVer.split('.')[0], 10);
|
|
25
|
+
out.push({
|
|
26
|
+
name: 'Node.js',
|
|
27
|
+
status: nodeMajor >= 20 ? 'ok' : 'fail',
|
|
28
|
+
detail: `${nodeVer}${nodeMajor >= 20 ? '' : ' — require >= 20'}`,
|
|
29
|
+
remedy: nodeMajor >= 20 ? undefined : 'Upgrade Node.js: https://nodejs.org',
|
|
30
|
+
});
|
|
31
|
+
// ── 2. Franklin version ───────────────────────────────────────────
|
|
32
|
+
out.push({
|
|
33
|
+
name: 'Franklin',
|
|
34
|
+
status: 'ok',
|
|
35
|
+
detail: `v${VERSION}`,
|
|
36
|
+
});
|
|
37
|
+
// ── 3. BLOCKRUN_DIR writable ──────────────────────────────────────
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
40
|
+
const probe = path.join(BLOCKRUN_DIR, '.doctor-probe');
|
|
41
|
+
fs.writeFileSync(probe, 'ok');
|
|
42
|
+
fs.unlinkSync(probe);
|
|
43
|
+
out.push({
|
|
44
|
+
name: 'Config directory',
|
|
45
|
+
status: 'ok',
|
|
46
|
+
detail: BLOCKRUN_DIR,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
out.push({
|
|
51
|
+
name: 'Config directory',
|
|
52
|
+
status: 'fail',
|
|
53
|
+
detail: `${BLOCKRUN_DIR} — ${err.message}`,
|
|
54
|
+
remedy: `Check permissions on ${BLOCKRUN_DIR} or unset HOME override`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// ── 4. Chain configuration ────────────────────────────────────────
|
|
58
|
+
let chain = null;
|
|
59
|
+
try {
|
|
60
|
+
chain = loadChain();
|
|
61
|
+
out.push({
|
|
62
|
+
name: 'Chain',
|
|
63
|
+
status: 'ok',
|
|
64
|
+
detail: chain,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
out.push({
|
|
69
|
+
name: 'Chain',
|
|
70
|
+
status: 'fail',
|
|
71
|
+
detail: `failed to load — ${err.message}`,
|
|
72
|
+
remedy: 'Run: franklin setup base (or: franklin setup solana)',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// ── 5. Wallet ─────────────────────────────────────────────────────
|
|
76
|
+
let walletBalance = null;
|
|
77
|
+
let walletAddress = '';
|
|
78
|
+
if (chain) {
|
|
79
|
+
try {
|
|
80
|
+
if (chain === 'solana') {
|
|
81
|
+
const client = await setupAgentSolanaWallet({ silent: true });
|
|
82
|
+
walletAddress = await client.getWalletAddress();
|
|
83
|
+
walletBalance = await client.getBalance();
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const client = setupAgentWallet({ silent: true });
|
|
87
|
+
walletAddress = client.getWalletAddress();
|
|
88
|
+
walletBalance = await client.getBalance();
|
|
89
|
+
}
|
|
90
|
+
out.push({
|
|
91
|
+
name: 'Wallet',
|
|
92
|
+
status: 'ok',
|
|
93
|
+
detail: `${walletAddress.slice(0, 10)}…${walletAddress.slice(-6)}`,
|
|
94
|
+
});
|
|
95
|
+
out.push({
|
|
96
|
+
name: 'USDC balance',
|
|
97
|
+
status: walletBalance > 0 ? 'ok' : 'warn',
|
|
98
|
+
detail: `$${walletBalance.toFixed(2)}${walletBalance === 0 ? ' — free-tier models only' : ''}`,
|
|
99
|
+
remedy: walletBalance === 0
|
|
100
|
+
? `Send USDC on ${chain} to ${walletAddress} to unlock paid models`
|
|
101
|
+
: undefined,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const msg = err.message || '';
|
|
106
|
+
out.push({
|
|
107
|
+
name: 'Wallet',
|
|
108
|
+
status: 'fail',
|
|
109
|
+
detail: `error — ${msg.slice(0, 120)}`,
|
|
110
|
+
remedy: msg.includes('ENOENT') || msg.includes('wallet') || msg.includes('key')
|
|
111
|
+
? 'Run: franklin setup'
|
|
112
|
+
: 'Check network / wallet file permissions',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ── 6. Gateway reachability ───────────────────────────────────────
|
|
117
|
+
if (chain) {
|
|
118
|
+
const apiUrl = API_URLS[chain];
|
|
119
|
+
try {
|
|
120
|
+
const ctl = new AbortController();
|
|
121
|
+
const t = setTimeout(() => ctl.abort(), 5000);
|
|
122
|
+
const res = await fetch(`${apiUrl}/health`, { signal: ctl.signal }).catch(() => null);
|
|
123
|
+
clearTimeout(t);
|
|
124
|
+
if (res && res.ok) {
|
|
125
|
+
out.push({
|
|
126
|
+
name: 'Gateway',
|
|
127
|
+
status: 'ok',
|
|
128
|
+
detail: apiUrl,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Fall back to a HEAD on the messages endpoint — some deployments
|
|
133
|
+
// don't expose /health but the API is up.
|
|
134
|
+
const ctl2 = new AbortController();
|
|
135
|
+
const t2 = setTimeout(() => ctl2.abort(), 5000);
|
|
136
|
+
const res2 = await fetch(`${apiUrl}/v1/messages`, {
|
|
137
|
+
method: 'HEAD',
|
|
138
|
+
signal: ctl2.signal,
|
|
139
|
+
}).catch(() => null);
|
|
140
|
+
clearTimeout(t2);
|
|
141
|
+
out.push({
|
|
142
|
+
name: 'Gateway',
|
|
143
|
+
status: res2 ? 'ok' : 'fail',
|
|
144
|
+
detail: res2 ? apiUrl : `unreachable: ${apiUrl}`,
|
|
145
|
+
remedy: res2 ? undefined : 'Check network or try the other chain',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
out.push({
|
|
151
|
+
name: 'Gateway',
|
|
152
|
+
status: 'fail',
|
|
153
|
+
detail: `${apiUrl} — ${err.message}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// ── 7. MCP config ─────────────────────────────────────────────────
|
|
158
|
+
const mcpPath = path.join(BLOCKRUN_DIR, 'mcp.json');
|
|
159
|
+
if (fs.existsSync(mcpPath)) {
|
|
160
|
+
try {
|
|
161
|
+
const raw = fs.readFileSync(mcpPath, 'utf-8');
|
|
162
|
+
const parsed = JSON.parse(raw);
|
|
163
|
+
const count = Object.keys(parsed.mcpServers || {}).length;
|
|
164
|
+
out.push({
|
|
165
|
+
name: 'MCP servers',
|
|
166
|
+
status: 'ok',
|
|
167
|
+
detail: `${count} configured in ${mcpPath}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
out.push({
|
|
172
|
+
name: 'MCP servers',
|
|
173
|
+
status: 'warn',
|
|
174
|
+
detail: `${mcpPath} has invalid JSON — ${err.message}`,
|
|
175
|
+
remedy: `Fix or delete ${mcpPath}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
out.push({
|
|
181
|
+
name: 'MCP servers',
|
|
182
|
+
status: 'ok',
|
|
183
|
+
detail: 'none configured',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// ── 8. Telemetry ──────────────────────────────────────────────────
|
|
187
|
+
const telEnabled = isTelemetryEnabled();
|
|
188
|
+
if (telEnabled) {
|
|
189
|
+
const records = readAllRecords();
|
|
190
|
+
out.push({
|
|
191
|
+
name: 'Telemetry',
|
|
192
|
+
status: 'ok',
|
|
193
|
+
detail: `enabled — ${records.length} session${records.length === 1 ? '' : 's'} recorded`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
out.push({
|
|
198
|
+
name: 'Telemetry',
|
|
199
|
+
status: 'ok',
|
|
200
|
+
detail: 'disabled (default)',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// ── 9. Shell / PATH hint ──────────────────────────────────────────
|
|
204
|
+
const which = process.env.PATH || '';
|
|
205
|
+
const hasHomebrew = which.includes('/opt/homebrew/bin') || which.includes('/usr/local/bin');
|
|
206
|
+
if (os.platform() === 'darwin' && !hasHomebrew) {
|
|
207
|
+
out.push({
|
|
208
|
+
name: 'PATH',
|
|
209
|
+
status: 'warn',
|
|
210
|
+
detail: 'Homebrew paths not in PATH',
|
|
211
|
+
remedy: 'Add /opt/homebrew/bin to PATH in ~/.zshrc',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
function printHuman(checks) {
|
|
217
|
+
console.log(chalk.bold('\n franklin doctor\n'));
|
|
218
|
+
for (const c of checks) {
|
|
219
|
+
const icon = c.status === 'ok' ? chalk.green('✓') :
|
|
220
|
+
c.status === 'warn' ? chalk.yellow('⚠') :
|
|
221
|
+
chalk.red('✗');
|
|
222
|
+
console.log(` ${icon} ${c.name.padEnd(18)} ${chalk.dim(c.detail)}`);
|
|
223
|
+
if (c.remedy) {
|
|
224
|
+
console.log(` ${chalk.dim('↳')} ${chalk.yellow(c.remedy)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const fails = checks.filter(c => c.status === 'fail').length;
|
|
228
|
+
const warns = checks.filter(c => c.status === 'warn').length;
|
|
229
|
+
console.log();
|
|
230
|
+
if (fails > 0) {
|
|
231
|
+
console.log(chalk.red(` ${fails} check${fails === 1 ? '' : 's'} failed. See remedies above.`));
|
|
232
|
+
}
|
|
233
|
+
else if (warns > 0) {
|
|
234
|
+
console.log(chalk.yellow(` All criticals ok. ${warns} warning${warns === 1 ? '' : 's'} above — safe to ignore for now.`));
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
console.log(chalk.green(' All clear. Ready to run: franklin'));
|
|
238
|
+
}
|
|
239
|
+
console.log();
|
|
240
|
+
}
|
|
241
|
+
export async function doctorCommand(opts = {}) {
|
|
242
|
+
const checks = await runChecks();
|
|
243
|
+
if (opts.json) {
|
|
244
|
+
const fails = checks.filter(c => c.status === 'fail').length;
|
|
245
|
+
process.stdout.write(JSON.stringify({ checks, healthy: fails === 0 }, null, 2) + '\n');
|
|
246
|
+
process.exit(fails > 0 ? 1 : 0);
|
|
247
|
+
}
|
|
248
|
+
printHuman(checks);
|
|
249
|
+
const fails = checks.filter(c => c.status === 'fail').length;
|
|
250
|
+
process.exit(fails > 0 ? 1 : 0);
|
|
251
|
+
}
|
package/dist/commands/start.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ interface StartOptions {
|
|
|
7
7
|
resume?: string | boolean | 'picker';
|
|
8
8
|
/** Continue: resume most recent session matching the current working directory */
|
|
9
9
|
continue?: boolean;
|
|
10
|
+
/** Hard USD cap on total session spend. Stops the loop when exceeded. */
|
|
11
|
+
maxSpend?: string | number;
|
|
12
|
+
/** Run a single prompt non-interactively, then exit. For cron/scripted use. */
|
|
13
|
+
prompt?: string;
|
|
10
14
|
}
|
|
11
15
|
export declare function startCommand(options: StartOptions): Promise<void>;
|
|
12
16
|
export {};
|
package/dist/commands/start.js
CHANGED
|
@@ -74,6 +74,49 @@ export async function startCommand(options) {
|
|
|
74
74
|
// Default: free NVIDIA model — zero wallet charges until user explicitly switches
|
|
75
75
|
model = 'nvidia/nemotron-ultra-253b';
|
|
76
76
|
}
|
|
77
|
+
const workDir = process.cwd();
|
|
78
|
+
// --prompt batch mode: skip all interactive startup UI/side effects so
|
|
79
|
+
// stdout stays clean for cron/scripts. Keep the capability surface to the
|
|
80
|
+
// built-ins only — no panel, no MCP autoconnect, no wallet/banner chatter.
|
|
81
|
+
if (options.prompt) {
|
|
82
|
+
if (options.resume === true || options.resume === 'picker') {
|
|
83
|
+
console.error(chalk.red('`--prompt` requires `--resume` to include an explicit session id.'));
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const systemInstructions = assembleInstructions(workDir, model);
|
|
88
|
+
const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities, model);
|
|
89
|
+
const { registerMoAConfig } = await import('../tools/moa.js');
|
|
90
|
+
registerMoAConfig(apiUrl, chain, model);
|
|
91
|
+
const capabilities = [...allCapabilities, subAgent];
|
|
92
|
+
if (options.debug) {
|
|
93
|
+
const issues = validateToolDescriptions(capabilities);
|
|
94
|
+
for (const issue of issues) {
|
|
95
|
+
console.error(`[validate] ${issue.severity}: ${issue.toolName} — ${issue.issue}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const agentConfig = {
|
|
99
|
+
model,
|
|
100
|
+
apiUrl,
|
|
101
|
+
chain,
|
|
102
|
+
systemInstructions,
|
|
103
|
+
capabilities,
|
|
104
|
+
maxTurns: 100,
|
|
105
|
+
workingDir: workDir,
|
|
106
|
+
permissionMode: 'trust',
|
|
107
|
+
debug: options.debug,
|
|
108
|
+
resumeSessionId: (typeof options.resume === 'string' && options.resume !== 'picker')
|
|
109
|
+
? options.resume
|
|
110
|
+
: continueResolvedId,
|
|
111
|
+
...(options.maxSpend != null
|
|
112
|
+
? { maxSpendUsd: Number(options.maxSpend) }
|
|
113
|
+
: {}),
|
|
114
|
+
};
|
|
115
|
+
const exitCode = await runOneShot(agentConfig, options.prompt);
|
|
116
|
+
flushStats();
|
|
117
|
+
process.exitCode = exitCode;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
77
120
|
// Warn when a paid model is active so users know they'll be charged
|
|
78
121
|
const FREE_MODELS = new Set([
|
|
79
122
|
'nvidia/nemotron-ultra-253b',
|
|
@@ -114,7 +157,6 @@ export async function startCommand(options) {
|
|
|
114
157
|
catch { /* migration is optional */ }
|
|
115
158
|
}
|
|
116
159
|
printBanner(version);
|
|
117
|
-
const workDir = process.cwd();
|
|
118
160
|
// Auto-start panel in background unless explicitly disabled.
|
|
119
161
|
// Binds loopback-only (wallet secrets on /api/wallet/secret — never expose on LAN).
|
|
120
162
|
let panelUrl;
|
|
@@ -240,9 +282,13 @@ export async function startCommand(options) {
|
|
|
240
282
|
workingDir: workDir,
|
|
241
283
|
// Non-TTY (piped) input = scripted mode → trust all tools automatically.
|
|
242
284
|
// Interactive TTY = default mode (prompts for Bash/Write/Edit).
|
|
243
|
-
|
|
285
|
+
// --prompt is also scripted; the cron driver never sees a TTY.
|
|
286
|
+
permissionMode: (options.trust || options.prompt || !process.stdin.isTTY) ? 'trust' : 'default',
|
|
244
287
|
debug: options.debug,
|
|
245
288
|
resumeSessionId,
|
|
289
|
+
...(options.maxSpend != null
|
|
290
|
+
? { maxSpendUsd: Number(options.maxSpend) }
|
|
291
|
+
: {}),
|
|
246
292
|
};
|
|
247
293
|
// Bootstrap learnings from existing CLAUDE.md on first run (async, non-blocking)
|
|
248
294
|
Promise.all([
|
|
@@ -262,6 +308,30 @@ export async function startCommand(options) {
|
|
|
262
308
|
await runWithBasicUI(agentConfig, model, workDir);
|
|
263
309
|
}
|
|
264
310
|
}
|
|
311
|
+
// ─── One-shot mode (franklin --prompt "...") ──────────────────────────────
|
|
312
|
+
// Used by cron drivers. Non-interactive, prints text deltas to stdout as
|
|
313
|
+
// they stream, honors --max-spend, exits 0 on completion / 1 on error.
|
|
314
|
+
async function runOneShot(agentConfig, prompt) {
|
|
315
|
+
let delivered = false;
|
|
316
|
+
let exitCode = 0;
|
|
317
|
+
const getInput = async () => {
|
|
318
|
+
if (delivered)
|
|
319
|
+
return null;
|
|
320
|
+
delivered = true;
|
|
321
|
+
return prompt;
|
|
322
|
+
};
|
|
323
|
+
await interactiveSession(agentConfig, getInput, (event) => {
|
|
324
|
+
if (event.kind === 'text_delta') {
|
|
325
|
+
process.stdout.write(event.text);
|
|
326
|
+
}
|
|
327
|
+
else if (event.kind === 'turn_done') {
|
|
328
|
+
if (event.reason === 'error')
|
|
329
|
+
exitCode = 1;
|
|
330
|
+
process.stdout.write('\n');
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
return exitCode;
|
|
334
|
+
}
|
|
265
335
|
// ─── Ink UI (interactive terminal) ─────────────────────────────────────────
|
|
266
336
|
async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, onBalanceReady, fetchBalance) {
|
|
267
337
|
const startSnapshot = snapshotStats();
|
package/dist/index.js
CHANGED
|
@@ -43,6 +43,8 @@ program
|
|
|
43
43
|
.option('--trust', 'Trust mode — skip permission prompts for all tools')
|
|
44
44
|
.option('-r, --resume [sessionId]', 'Resume a session by ID (or show picker if omitted)')
|
|
45
45
|
.option('-c, --continue', 'Continue the most recent session in this directory')
|
|
46
|
+
.option('--max-spend <usd>', 'Hard USD cap on total session API spend — session stops when exceeded')
|
|
47
|
+
.option('-p, --prompt <text>', 'Run a single prompt non-interactively (for cron/scripted use)')
|
|
46
48
|
.action((options) => startCommand({ ...options, version }));
|
|
47
49
|
program
|
|
48
50
|
.command('resume [sessionId]')
|
|
@@ -166,6 +168,14 @@ program
|
|
|
166
168
|
});
|
|
167
169
|
}
|
|
168
170
|
}
|
|
171
|
+
program
|
|
172
|
+
.command('doctor')
|
|
173
|
+
.description('One-command health check (node, wallet, chain, gateway, MCP, telemetry)')
|
|
174
|
+
.option('--json', 'Machine-readable output')
|
|
175
|
+
.action(async (opts) => {
|
|
176
|
+
const { doctorCommand } = await import('./commands/doctor.js');
|
|
177
|
+
await doctorCommand(opts);
|
|
178
|
+
});
|
|
169
179
|
program
|
|
170
180
|
.command('telemetry [action]')
|
|
171
181
|
.description('Manage opt-in local telemetry (status|enable|disable|view|summary)')
|
|
@@ -201,7 +211,7 @@ const args = process.argv.slice(2);
|
|
|
201
211
|
const firstArg = args[0];
|
|
202
212
|
const HELP_FLAGS = new Set(['-h', '--help']);
|
|
203
213
|
const VERSION_FLAGS = new Set(['-V', '--version']);
|
|
204
|
-
const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model', '-r', '--resume', '-c', '--continue']);
|
|
214
|
+
const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model', '-r', '--resume', '-c', '--continue', '-p', '--prompt', '--max-spend']);
|
|
205
215
|
function hasAnyFlag(argv, flags) {
|
|
206
216
|
return argv.some(arg => flags.has(arg));
|
|
207
217
|
}
|
|
@@ -219,6 +229,12 @@ function parseStartFlags(argv, startIdx = 0) {
|
|
|
219
229
|
else if ((arg === '-m' || arg === '--model') && argv[i + 1]) {
|
|
220
230
|
opts.model = argv[++i];
|
|
221
231
|
}
|
|
232
|
+
else if ((arg === '-p' || arg === '--prompt') && argv[i + 1]) {
|
|
233
|
+
opts.prompt = argv[++i];
|
|
234
|
+
}
|
|
235
|
+
else if (arg === '--max-spend' && argv[i + 1]) {
|
|
236
|
+
opts.maxSpend = argv[++i];
|
|
237
|
+
}
|
|
222
238
|
else if (arg === '-c' || arg === '--continue') {
|
|
223
239
|
opts.continue = true;
|
|
224
240
|
}
|