@blockrun/franklin 3.15.68 → 3.15.70

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.
@@ -315,6 +315,7 @@ function getToolPatternsSection() {
315
315
  - **Making changes**: Read the file → Edit with targeted replacement → verify the edit worked (Read again or run tests). Never Edit without Reading first.
316
316
  - **Running commands**: Use Bash for shell operations that have no dedicated tool. Chain commands with && when sequential. Use separate Bash calls when you need to inspect intermediate output.
317
317
  - **Research**: WebSearch for discovery → WebFetch for specific URLs from search results. Don't WebFetch URLs you invented.
318
+ - **Comparing products / services / APIs** (e.g. "X vs Y, which is better"): start with **WebSearch / ExaSearch / WebFetch** on each vendor's docs/pricing pages. Do NOT \`curl\` the live API as a first move — third-party APIs sit behind WAFs that 401/403/"fault filter abort" on probes, and burning 10+ Bash calls cycling through auth schemes is pure waste. Only hit the live API after public docs have been read AND the user explicitly asked for a hands-on test.
318
319
  - **Complex tasks**: Use Agent to spawn sub-agents for 2+ independent research or implementation tasks. Don't do sequentially what can be done in parallel.
319
320
  - **Multiple independent lookups**: Call all tools in a single response. NEVER make sequential calls when parallel calls would work.
320
321
  - **Long-running iteration (>20 items)**: Use the **Detach** tool, not turn-by-turn loops. Write a script that iterates and persists a checkpoint file (e.g. \`./.franklin/<task>.checkpoint.json\` with cursor + processedCount), then start it via Detach — \`{ label: "scrape stargazers", command: "node fetch.mjs" }\`. Detach returns a runId immediately and the work continues even if Franklin exits. Inspect with \`franklin task tail <runId> --follow\` / \`task wait <runId>\` / \`task cancel <runId>\`. The agent's job is to design and orchestrate, not to be the for-loop. Pattern fits paginated APIs, batch enrichment, large CSV emit, anything where the loop body is deterministic.
@@ -335,12 +336,15 @@ Your training data is frozen in the past. Live-world questions MUST be answered
335
336
 
336
337
  If you find yourself about to emit one of these, stop and call the tool instead. If you don't know which ticker the user means, call ExaSearch or AskUser — never deflect.
337
338
 
338
- **Prediction markets (PredictionMarket).** When the user asks about real-world odds — elections, "will X happen by year-end", "Polymarket on Y", "Kalshi market for Z", "what are the odds of recession" — use **PredictionMarket** instead of guessing. Four actions:
339
- - \`searchPolymarket\` (\$0.001) and \`searchKalshi\` (\$0.001) search markets by keyword. Run them **in parallel** when the user wants the current odds; comparing implied probability across two venues is the high-value answer.
340
- - \`crossPlatform\` (\$0.005) pre-matched pairs of equivalent markets across Polymarket and Kalshi. Use when the user wants arbitrage candidates or wants to know "where does the consensus disagree".
341
- - \`smartMoney\` (\$0.005) top-wallet flow on a specific Polymarket \`condition_id\`. Get the \`condition_id\` from a prior \`searchPolymarket\` call.
339
+ **Prediction markets (PredictionMarket).** When the user asks about real-world odds — elections, "will X happen by year-end", "Polymarket on Y", "Kalshi market for Z", "what are the odds of recession" — use **PredictionMarket** instead of guessing. Seven actions, route by intent:
340
+ - "is there a market on X anywhere?" / unknown which platform \`searchAll\` (\$0.005) single call across Polymarket+Kalshi+Limitless+Opinion+Predict.Fun.
341
+ - "what are the odds on Polymarket / Kalshi specifically" \`searchPolymarket\` (\$0.001) and \`searchKalshi\` (\$0.001) **in parallel**; comparing implied probability across the two venues is the high-value answer.
342
+ - "where do Polymarket and Kalshi disagree / arbitrage" \`crossPlatform\` (\$0.005) returns pre-matched pairs.
343
+ - "who's profitable / top traders / who should I follow on Polymarket" → \`leaderboard\` (\$0.001) — global top wallets by P&L.
344
+ - "how is wallet 0xabc doing / show this trader's P&L / are they profitable" → \`walletProfile\` (\$0.005) with \`wallets="<address>"\` (comma-separated for batch).
345
+ - "what are smart traders betting on right now / smart money flow" → \`smartActivity\` (\$0.005) — markets where high-P&L wallets are positioning.
342
346
 
343
- NEVER answer "what are the odds of X" from training-data memory — these are live markets that move every minute. NEVER claim "Polymarket doesn't have a market on this" without running \`searchPolymarket\` first. If both Polymarket and Kalshi return zero markets, say so explicitly with the searches you tried, then offer to broaden the query.
347
+ NEVER answer "what are the odds of X" from training-data memory — these are live markets that move every minute. NEVER claim "no market on this" without running \`searchAll\` (or at least \`searchPolymarket\`) first. If a search returns zero, say so with the query you tried and offer to broaden.
344
348
 
345
349
  **Trading verdicts (TradingSignal).** When the user asks "how does $TICKER look" / "should I buy X" / "is BTC overbought":
346
350
  - Run **TradingSignal** with default lookback (90d). Lower values leave MACD undefined.
package/dist/agent/llm.js CHANGED
@@ -780,6 +780,36 @@ export class ModelClient {
780
780
  collected.push({ type: 'text', text: currentText });
781
781
  }
782
782
  }
783
+ // Fallback: some non-Anthropic providers behind the gateway (e.g. zai/glm-5.1)
784
+ // emit `message_start` with `output_tokens: 1` as a placeholder and never
785
+ // send a final `message_delta` carrying the real count. The audit log
786
+ // then records `outputTokens: 1` for every call in the session even
787
+ // though the model produced rich tool_use/text content. Verified
788
+ // 2026-05-05 in a real session: 50 audit rows, 17 distinct multi-line
789
+ // bash commands, total `output_tokens` summed to 1,154 — most rows
790
+ // showed 1. We estimate from the collected payload byte length when
791
+ // the reported count is implausibly low for the actual content.
792
+ if (usage.outputTokens <= 1 && collected.length > 0) {
793
+ let bytes = 0;
794
+ for (const part of collected) {
795
+ if (part.type === 'text') {
796
+ bytes += part.text?.length ?? 0;
797
+ }
798
+ else if (part.type === 'tool_use') {
799
+ const tu = part;
800
+ bytes += (tu.name?.length ?? 0) + JSON.stringify(tu.input ?? {}).length;
801
+ }
802
+ else if (part.type === 'thinking') {
803
+ bytes += part.thinking?.length ?? 0;
804
+ }
805
+ }
806
+ // ~4 chars/token is a rough but standard tokenizer-agnostic rule.
807
+ // Only override when the estimate is noticeably larger — otherwise
808
+ // trust the wire value (a genuinely tiny response should stay tiny).
809
+ const estimated = Math.ceil(bytes / 4);
810
+ if (estimated > usage.outputTokens + 5)
811
+ usage.outputTokens = estimated;
812
+ }
783
813
  return { content: collected, usage, stopReason };
784
814
  }
785
815
  // ─── Payment ───────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@
3
3
  * The core reasoning-action cycle: prompt → model → extract capabilities → execute → repeat.
4
4
  */
5
5
  import type { AgentConfig, ContentPart, Dialogue, StreamEvent } from './types.js';
6
+ export declare function isExternalWallFailure(toolName: string, output: string, isError?: boolean): boolean;
6
7
  /**
7
8
  * Detect when the gateway leaked an upstream rate-limit / quota error as a
8
9
  * 200-OK text content block instead of a real HTTP error. The Anthropic
@@ -46,6 +46,19 @@ import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions,
46
46
  function replaceHistory(target, replacement) {
47
47
  target.splice(0, target.length, ...replacement);
48
48
  }
49
+ const EXTERNAL_WALL_FAILURE_PATTERN = /\b(?:401|403|429|5\d{2})\b|\bunauthor|\bforbid|\bWAF\b|\bcloudflare\b|\bfault filter\b|\bblocked\b|\binvalid (?:auth|api|token|key|bearer)\b/i;
50
+ export function isExternalWallFailure(toolName, output, isError) {
51
+ if (toolName === 'WebFetch') {
52
+ return isError === true || EXTERNAL_WALL_FAILURE_PATTERN.test(output);
53
+ }
54
+ if (toolName === 'Bash') {
55
+ // Bash is a general-purpose local tool. Non-zero exits from tests,
56
+ // builds, git, etc. are useful debugging signal, not proof that the
57
+ // model is thrashing against an external auth/firewall wall.
58
+ return output.length > 0 && EXTERNAL_WALL_FAILURE_PATTERN.test(output);
59
+ }
60
+ return false;
61
+ }
49
62
  // ─── Pushback detection ───────────────────────────────────────────────────
50
63
  // Formerly a pair of regex lists (PUSHBACK_STRONG / PUSHBACK_WEAK) plus a
51
64
  // claim-on-prior-turn check — ~70 lines of keyword heuristics. Replaced by
@@ -696,6 +709,10 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
696
709
  const serverErrorsByModel = new Map();
697
710
  const SERVER_ERROR_STREAK_BEFORE_SWITCH = 2;
698
711
  let compactFailures = 0;
712
+ // Research-bloat compaction is fire-once per turn. A later turn can hit
713
+ // the trigger organically after the first compact, but firing twice from
714
+ // the same threshold would flap on every iteration once crossed.
715
+ let bloatCompactedThisTurn = false;
699
716
  let maxTokensOverride;
700
717
  const turnIdleReference = lastSessionActivity;
701
718
  lastSessionActivity = Date.now();
@@ -754,6 +771,25 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
754
771
  // ── No-progress guardrail: kill infinite tiny-response loops ──
755
772
  let consecutiveTinyResponses = 0; // Count of consecutive calls with <10 output tokens
756
773
  const MAX_TINY_RESPONSES = 2; // Break after N tiny responses — if 2 calls return near-empty, something is wrong
774
+ // ── Turn cost accumulator ──
775
+ // Surfaced in cap-exceeded messages so the user sees what the wasted
776
+ // turn actually cost ("$0.05 spent before this turn was killed") instead
777
+ // of just "tool limit exceeded". sessionCostUsd is too coarse — it
778
+ // includes earlier productive turns the user got real value from.
779
+ let turnCostUsd = 0;
780
+ // ── Failed-external-call guardrail ──
781
+ // The signature loop guard only catches exact-input repeats. It misses
782
+ // "thrashing exploration": model calls Bash 17 different ways trying to
783
+ // fix a 401 against the same dead endpoint. Verified 2026-05-05 in a
784
+ // real session: glm-5.1 burned 50 calls / $0.05 trying every auth
785
+ // variation against api.querit.ai (Cloudflare WAF blocked them all)
786
+ // before the signature guard finally fired on the first exact repeat.
787
+ // We count consecutive Bash/WebFetch calls whose output looks like a
788
+ // network/auth failure; reset on any non-failed external call. Five
789
+ // failures in a row is a wall, not exploration.
790
+ let consecutiveFailedExternal = 0;
791
+ const MAX_CONSECUTIVE_FAILED_EXTERNAL = 5;
792
+ const EXTERNAL_TOOL_NAMES = new Set(['Bash', 'WebFetch']);
757
793
  // ── Turn analysis (one classifier call, drives routing + prefetch) ──
758
794
  // Single LLM pass that answers every routing-adjacent question the
759
795
  // harness needs BEFORE the main model runs: tier, ticker intent,
@@ -893,6 +929,45 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
893
929
  logger.warn(`[franklin] Compaction failed (${compactFailures}/3): ${compactErr.message}`);
894
930
  }
895
931
  }
932
+ // ── Research-bloat compaction (fires before context-window) ──
933
+ // The window-based trigger above only fires near 172K tokens for a
934
+ // 200K-context model. Research sessions burn money long before that:
935
+ // verified 2026-05-05 in a real audit, a glm-5.1 session hit
936
+ // $0.18 / 177 calls / 3.17M cumulative input — average per-call input
937
+ // grew to 17.9K because every tool result kept replaying. Top-spend
938
+ // session in the same log: $6.67 on gemini-2.5-flash in 121 calls,
939
+ // never approached its 1M-token compaction threshold. Compact here
940
+ // when the turn has accumulated lots of tool calls AND real spend,
941
+ // even though the context window isn't close to full.
942
+ if (!bloatCompactedThisTurn &&
943
+ compactFailures < 3 &&
944
+ turnToolCalls > 30 &&
945
+ turnCostUsd > 0.05) {
946
+ try {
947
+ const beforeTokens = estimateHistoryTokens(history);
948
+ const { history: compacted, compacted: didCompact } = await forceCompact(history, config.model, client, config.debug);
949
+ if (didCompact) {
950
+ replaceHistory(history, compacted);
951
+ resetTokenAnchor();
952
+ bloatCompactedThisTurn = true;
953
+ const afterTokens = estimateHistoryTokens(history);
954
+ const pct = beforeTokens > 0
955
+ ? Math.round((1 - afterTokens / beforeTokens) * 100)
956
+ : 0;
957
+ onEvent({
958
+ kind: 'text_delta',
959
+ text: `\n*🗜 Research-bloat compact: ${turnToolCalls} tool calls / $${turnCostUsd.toFixed(4)} this turn — summarizing ~${(beforeTokens / 1000).toFixed(0)}K → ~${(afterTokens / 1000).toFixed(0)}K tokens (saved ${pct}%)*\n\n`,
960
+ });
961
+ logger.info(`[franklin] Research-bloat compacted at ${turnToolCalls} calls / $${turnCostUsd.toFixed(4)}: ~${afterTokens} tokens`);
962
+ }
963
+ }
964
+ catch (compactErr) {
965
+ // Don't increment compactFailures — that gate is for the
966
+ // window-based path. A failed bloat compact just means we keep
967
+ // going at the higher per-call cost; not catastrophic.
968
+ logger.warn(`[franklin] Bloat compaction failed: ${compactErr.message}`);
969
+ }
970
+ }
896
971
  // Inject ultrathink instruction when mode is active
897
972
  const systemParts = [...config.systemInstructions];
898
973
  if (config.ultrathink) {
@@ -1432,6 +1507,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1432
1507
  sessionInputTokens += inputTokens;
1433
1508
  sessionOutputTokens += usage.outputTokens;
1434
1509
  sessionCostUsd += costEstimate;
1510
+ turnCostUsd += costEstimate;
1435
1511
  const opusCost = (inputTokens / 1_000_000) * OPUS_PRICING.input
1436
1512
  + (usage.outputTokens / 1_000_000) * OPUS_PRICING.output;
1437
1513
  sessionSavedVsOpus += Math.max(0, opusCost - costEstimate);
@@ -1661,7 +1737,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1661
1737
  }
1662
1738
  // ── Tool call guardrails ──
1663
1739
  turnToolCalls += results.length;
1664
- for (const [inv] of results) {
1740
+ for (const [inv, result] of results) {
1665
1741
  const name = inv.name;
1666
1742
  turnToolCounts.set(name, (turnToolCounts.get(name) || 0) + 1);
1667
1743
  // Track (tool, input)-signature for the loop detector below.
@@ -1674,6 +1750,16 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1674
1750
  if (name === 'Read' && inv.input.file_path) {
1675
1751
  readFileCache.add(inv.input.file_path);
1676
1752
  }
1753
+ // Failed-external-call streak: count consecutive Bash/WebFetch calls
1754
+ // whose output indicates a network/auth wall. Reset on any non-failed
1755
+ // external call so legitimate retry-then-succeed paths aren't punished.
1756
+ if (EXTERNAL_TOOL_NAMES.has(name)) {
1757
+ const looksFailed = isExternalWallFailure(name, typeof result.output === 'string' ? result.output : '', result.isError);
1758
+ if (looksFailed)
1759
+ consecutiveFailedExternal++;
1760
+ else
1761
+ consecutiveFailedExternal = 0;
1762
+ }
1677
1763
  }
1678
1764
  // Refresh activity timestamp after tool execution
1679
1765
  lastSessionActivity = Date.now();
@@ -1807,11 +1893,17 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1807
1893
  toolCapWarned = true;
1808
1894
  logger.warn(`[franklin] Tool call cap hit: ${turnToolCalls} calls this turn (soft cap ${MAX_TOOL_CALLS_PER_TURN}, hard cap ${HARD_TOOL_CAP})`);
1809
1895
  }
1896
+ // Format spend-so-far for cap messages — surfacing the dollar amount
1897
+ // tells the user the real impact ("$0.05 wasted") instead of just
1898
+ // "tool limit exceeded" which doesn't convey severity.
1899
+ const spendNote = turnCostUsd > 0
1900
+ ? `${turnToolCalls} tool calls, $${turnCostUsd.toFixed(4)} spent this turn`
1901
+ : `${turnToolCalls} tool calls this turn`;
1810
1902
  if (turnToolCalls >= HARD_TOOL_CAP) {
1811
1903
  logger.error(`[franklin] Hard tool cap exceeded (${turnToolCalls}) — ending turn to prevent runaway`);
1812
1904
  onEvent({
1813
1905
  kind: 'text_delta',
1814
- text: `\n\n⚠️ Tool call limit exceeded (${turnToolCalls}/${HARD_TOOL_CAP}). Ending turn to prevent runaway loop. Try rephrasing or use \`/model\` to switch.\n`,
1906
+ text: `\n\n⚠️ Runaway loop stopped: ${spendNote}, hit hard cap of ${HARD_TOOL_CAP}. Try rephrasing or use \`/model\` to switch.\n`,
1815
1907
  });
1816
1908
  onEvent({ kind: 'turn_done', reason: 'cap_exceeded' });
1817
1909
  break;
@@ -1829,7 +1921,22 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1829
1921
  logger.error(`[franklin] Signature-loop hard stop: \`${toolName}\` called with identical input ${stuckSignature.count} times this turn — ending turn`);
1830
1922
  onEvent({
1831
1923
  kind: 'text_delta',
1832
- text: `\n\n⚠️ ${toolName} called ${stuckSignature.count}× with the same input this turn — that's a real loop, not exploration. Ending turn. Rephrase what you actually need, or try \`/model\` to switch.\n`,
1924
+ text: `\n\n⚠️ Loop stopped: ${spendNote} before \`${toolName}\` repeated the same input ${stuckSignature.count}×. Rephrase what you need, or try \`/model\` to switch.\n`,
1925
+ });
1926
+ onEvent({ kind: 'turn_done', reason: 'cap_exceeded' });
1927
+ break;
1928
+ }
1929
+ // Thrashing-against-a-wall hard stop (3.15.69). Catches the case
1930
+ // where each call is structurally distinct (different headers, methods,
1931
+ // auth schemes, query params) but every one returns 4xx/5xx/WAF.
1932
+ // Verified 2026-05-05: glm-5.1 burned 50 calls / $0.05 cycling through
1933
+ // ~17 curl variants against Cloudflare-blocked api.querit.ai — every
1934
+ // input distinct so the signature guard above couldn't help.
1935
+ if (consecutiveFailedExternal >= MAX_CONSECUTIVE_FAILED_EXTERNAL) {
1936
+ logger.error(`[franklin] Failed-external-call streak: ${consecutiveFailedExternal} consecutive Bash/WebFetch calls returned auth/network errors — ending turn`);
1937
+ onEvent({
1938
+ kind: 'text_delta',
1939
+ text: `\n\n⚠️ Hitting a wall: ${consecutiveFailedExternal} consecutive external calls returned auth/firewall errors (${spendNote}). The endpoint or credentials likely don't work. Try a different approach, or use \`/model\` to switch.\n`,
1833
1940
  });
1834
1941
  onEvent({ kind: 'turn_done', reason: 'cap_exceeded' });
1835
1942
  break;
@@ -16,18 +16,42 @@ function detectSources() {
16
16
  const sources = [];
17
17
  const home = os.homedir();
18
18
  // ── `~/.claude/` config dir (used by several agent CLIs) ──
19
+ // Real Claude Code (2026 layout) writes:
20
+ // ~/.claude.json (top-level, mcpServers + global state)
21
+ // ~/.claude/CLAUDE.md (global instructions)
22
+ // ~/.claude/projects/<slug>/<uuid>.jsonl (one file per session)
23
+ // ~/.claude/projects/<slug>/memory/*.md (project memories)
24
+ // Older agents and pre-3.x Claude Code variants wrote:
25
+ // ~/.claude/mcp.json
26
+ // ~/.claude/history.jsonl
27
+ // We support both — prefer the new layout but fall back so users with
28
+ // legacy state still get their data imported.
19
29
  const claudeDir = path.join(home, '.claude');
20
- if (fs.existsSync(claudeDir)) {
30
+ const claudeJson = path.join(home, '.claude.json');
31
+ const hasClaudeData = fs.existsSync(claudeDir) || fs.existsSync(claudeJson);
32
+ if (hasClaudeData) {
21
33
  const items = [];
22
- // MCP servers
23
- const claudeMcp = path.join(claudeDir, 'mcp.json');
24
- if (fs.existsSync(claudeMcp)) {
34
+ // MCP servers — prefer top-level ~/.claude.json (new layout); fall back
35
+ // to legacy ~/.claude/mcp.json. Only add one item; whichever we find
36
+ // first is what migrateMcp() will read.
37
+ const newMcpHasServers = fileHasMcpServers(claudeJson);
38
+ const legacyMcp = path.join(claudeDir, 'mcp.json');
39
+ if (newMcpHasServers) {
25
40
  items.push({
26
- label: 'MCP servers',
27
- source: claudeMcp,
41
+ label: 'MCP servers (~/.claude.json)',
42
+ source: claudeJson,
28
43
  target: path.join(BLOCKRUN_DIR, 'mcp.json'),
29
- size: fileSize(claudeMcp),
30
- transform: () => migrateMcp(claudeMcp),
44
+ size: fileSize(claudeJson),
45
+ transform: () => migrateMcp(claudeJson),
46
+ });
47
+ }
48
+ else if (fs.existsSync(legacyMcp)) {
49
+ items.push({
50
+ label: 'MCP servers (legacy ~/.claude/mcp.json)',
51
+ source: legacyMcp,
52
+ target: path.join(BLOCKRUN_DIR, 'mcp.json'),
53
+ size: fileSize(legacyMcp),
54
+ transform: () => migrateMcp(legacyMcp),
31
55
  });
32
56
  }
33
57
  // Global instructions → learnings
@@ -41,20 +65,33 @@ function detectSources() {
41
65
  transform: () => migrateInstructions(claudeMd),
42
66
  });
43
67
  }
44
- // Session history
45
- const claudeHistory = path.join(claudeDir, 'history.jsonl');
46
- if (fs.existsSync(claudeHistory)) {
47
- const lines = countLines(claudeHistory);
68
+ // Session history — prefer per-project session JSONLs (new layout); fall
69
+ // back to legacy ~/.claude/history.jsonl. The new layout preserves session
70
+ // boundaries (one file = one conversation) instead of collapsing every
71
+ // message into a daily blob.
72
+ const projectsDir = path.join(claudeDir, 'projects');
73
+ const sessionFiles = fs.existsSync(projectsDir) ? findClaudeCodeSessionFiles(projectsDir) : [];
74
+ const legacyHistory = path.join(claudeDir, 'history.jsonl');
75
+ if (sessionFiles.length > 0) {
48
76
  items.push({
49
- label: `Session history (${lines.toLocaleString()} messages)`,
50
- source: claudeHistory,
77
+ label: `Session history (${sessionFiles.length.toLocaleString()} sessions)`,
78
+ source: projectsDir,
51
79
  target: path.join(BLOCKRUN_DIR, 'sessions'),
52
- size: fileSize(claudeHistory),
53
- transform: () => migrateSessions(claudeHistory),
80
+ size: `${sessionFiles.length} files`,
81
+ transform: () => migrateClaudeCodeSessions(sessionFiles),
82
+ });
83
+ }
84
+ else if (fs.existsSync(legacyHistory)) {
85
+ const lines = countLines(legacyHistory);
86
+ items.push({
87
+ label: `Session history (legacy, ${lines.toLocaleString()} messages)`,
88
+ source: legacyHistory,
89
+ target: path.join(BLOCKRUN_DIR, 'sessions'),
90
+ size: fileSize(legacyHistory),
91
+ transform: () => migrateSessions(legacyHistory),
54
92
  });
55
93
  }
56
94
  // Project memory files
57
- const projectsDir = path.join(claudeDir, 'projects');
58
95
  if (fs.existsSync(projectsDir)) {
59
96
  const memoryFiles = findMemoryFiles(projectsDir);
60
97
  if (memoryFiles.length > 0) {
@@ -95,7 +132,10 @@ function detectSources() {
95
132
  function migrateMcp(source) {
96
133
  const target = path.join(BLOCKRUN_DIR, 'mcp.json');
97
134
  const raw = JSON.parse(fs.readFileSync(source, 'utf-8'));
98
- // Source format: { mcpServers: { name: { command, args, env } } }
135
+ // Source format (Claude Code ~/.claude.json or legacy mcp.json):
136
+ // { mcpServers: { name: { type?, transport?, command, args, env? } } }
137
+ // ~/.claude.json wraps mcpServers among hundreds of unrelated state keys —
138
+ // we only read the one field.
99
139
  // Franklin format: { mcpServers: { name: { transport, command, args, label } } }
100
140
  const servers = {};
101
141
  const skipped = [];
@@ -121,7 +161,8 @@ function migrateMcp(source) {
121
161
  continue;
122
162
  }
123
163
  servers[name] = {
124
- transport: config.transport || 'stdio',
164
+ // Claude Code uses `type`; older agents used `transport`. Accept both.
165
+ transport: config.transport || config.type || 'stdio',
125
166
  command: config.command,
126
167
  args: config.args || [],
127
168
  label: name,
@@ -199,6 +240,143 @@ function migrateInstructions(source) {
199
240
  console.log(chalk.dim(' ○ No extractable preferences found'));
200
241
  }
201
242
  }
243
+ /**
244
+ * Import per-session JSONL files written by current Claude Code (2026 layout).
245
+ * One source file = one Franklin session — we preserve session boundaries
246
+ * instead of mashing everything into a daily blob like the legacy importer.
247
+ *
248
+ * Source line shape:
249
+ * { type: "user"|"assistant"|"attachment"|"permission-mode"|...,
250
+ * message?: { role, content }, timestamp, sessionId, cwd }
251
+ * Target Dialogue line shape: { role, content }
252
+ */
253
+ function migrateClaudeCodeSessions(sessionFiles) {
254
+ const sessionsDir = path.join(BLOCKRUN_DIR, 'sessions');
255
+ fs.mkdirSync(sessionsDir, { recursive: true });
256
+ let imported = 0;
257
+ let skipped = 0;
258
+ let totalTurns = 0;
259
+ for (const file of sessionFiles) {
260
+ const sessionId = path.basename(file, '.jsonl');
261
+ const targetJsonl = path.join(sessionsDir, `${sessionId}.jsonl`);
262
+ const targetMeta = path.join(sessionsDir, `${sessionId}.meta.json`);
263
+ // Don't re-import on a second run — the user might have already
264
+ // resumed and added turns to the imported session.
265
+ if (fs.existsSync(targetMeta)) {
266
+ skipped++;
267
+ continue;
268
+ }
269
+ let raw;
270
+ try {
271
+ raw = fs.readFileSync(file, 'utf-8');
272
+ }
273
+ catch {
274
+ continue;
275
+ }
276
+ const dialogues = [];
277
+ let firstTs = 0;
278
+ let lastTs = 0;
279
+ let workDir = os.homedir();
280
+ let model = 'claude-code-import';
281
+ for (const line of raw.split('\n')) {
282
+ const trimmed = line.trim();
283
+ if (!trimmed)
284
+ continue;
285
+ let entry;
286
+ try {
287
+ entry = JSON.parse(trimmed);
288
+ }
289
+ catch {
290
+ continue;
291
+ }
292
+ // Track timestamps + cwd from any line that has them.
293
+ const ts = entry.timestamp;
294
+ if (typeof ts === 'string') {
295
+ const t = Date.parse(ts);
296
+ if (Number.isFinite(t)) {
297
+ if (!firstTs || t < firstTs)
298
+ firstTs = t;
299
+ if (t > lastTs)
300
+ lastTs = t;
301
+ }
302
+ }
303
+ if (typeof entry.cwd === 'string' && entry.cwd)
304
+ workDir = entry.cwd;
305
+ // Only user/assistant turns become Franklin Dialogue lines. Everything
306
+ // else (attachments, permission-mode, summary, system) is metadata
307
+ // we don't replay.
308
+ if (entry.type !== 'user' && entry.type !== 'assistant')
309
+ continue;
310
+ const msg = entry.message;
311
+ if (!msg || (msg.role !== 'user' && msg.role !== 'assistant'))
312
+ continue;
313
+ if (typeof msg.model === 'string')
314
+ model = msg.model;
315
+ dialogues.push(JSON.stringify({ role: msg.role, content: msg.content }));
316
+ }
317
+ if (dialogues.length === 0) {
318
+ skipped++;
319
+ continue;
320
+ }
321
+ fs.writeFileSync(targetJsonl, dialogues.join('\n') + '\n');
322
+ fs.writeFileSync(targetMeta, JSON.stringify({
323
+ id: sessionId,
324
+ model,
325
+ workDir,
326
+ createdAt: firstTs || Date.now(),
327
+ updatedAt: lastTs || Date.now(),
328
+ turnCount: Math.floor(dialogues.length / 2),
329
+ messageCount: dialogues.length,
330
+ imported: true,
331
+ }, null, 2));
332
+ imported++;
333
+ totalTurns += dialogues.length;
334
+ }
335
+ const skipNote = skipped > 0 ? chalk.dim(` (${skipped} skipped)`) : '';
336
+ console.log(chalk.green(` ✓ ${imported} session(s) imported, ${totalTurns.toLocaleString()} turns${skipNote}`));
337
+ }
338
+ /** Walk ~/.claude/projects/<slug>/*.jsonl — one file per Claude Code session. */
339
+ function findClaudeCodeSessionFiles(projectsDir) {
340
+ const out = [];
341
+ let projects = [];
342
+ try {
343
+ projects = fs.readdirSync(projectsDir);
344
+ }
345
+ catch {
346
+ return out;
347
+ }
348
+ for (const project of projects) {
349
+ const projectPath = path.join(projectsDir, project);
350
+ let entries = [];
351
+ try {
352
+ const stat = fs.statSync(projectPath);
353
+ if (!stat.isDirectory())
354
+ continue;
355
+ entries = fs.readdirSync(projectPath);
356
+ }
357
+ catch {
358
+ continue;
359
+ }
360
+ for (const entry of entries) {
361
+ if (!entry.endsWith('.jsonl'))
362
+ continue;
363
+ out.push(path.join(projectPath, entry));
364
+ }
365
+ }
366
+ return out;
367
+ }
368
+ /** True iff the file is JSON with a non-empty mcpServers object. */
369
+ function fileHasMcpServers(p) {
370
+ try {
371
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
372
+ return !!raw && typeof raw === 'object' &&
373
+ !!raw.mcpServers && typeof raw.mcpServers === 'object' &&
374
+ Object.keys(raw.mcpServers).length > 0;
375
+ }
376
+ catch {
377
+ return false;
378
+ }
379
+ }
202
380
  function migrateSessions(source) {
203
381
  const sessionsDir = path.join(BLOCKRUN_DIR, 'sessions');
204
382
  fs.mkdirSync(sessionsDir, { recursive: true });
@@ -231,7 +409,9 @@ function migrateSessions(source) {
231
409
  if (fs.existsSync(sessionFile))
232
410
  continue;
233
411
  fs.writeFileSync(sessionFile, msgs.join('\n') + '\n');
234
- // Create metadata
412
+ // Create metadata. `imported: true` shields these from pruneOldSessions —
413
+ // a fresh import of 200+ historical sessions would otherwise be deleted
414
+ // on the next `franklin` launch when the agent loop prunes to 20.
235
415
  const meta = {
236
416
  id: sessionId,
237
417
  model: 'imported',
@@ -240,6 +420,7 @@ function migrateSessions(source) {
240
420
  updatedAt: Date.now(),
241
421
  turnCount: Math.floor(msgs.length / 2),
242
422
  messageCount: msgs.length,
423
+ imported: true,
243
424
  };
244
425
  fs.writeFileSync(path.join(sessionsDir, `${sessionId}.meta.json`), JSON.stringify(meta, null, 2));
245
426
  imported++;
@@ -340,7 +521,7 @@ export async function migrateCommand() {
340
521
  const sources = detectSources();
341
522
  if (sources.length === 0) {
342
523
  console.log(chalk.dim(' No other AI tools detected. Nothing to migrate.\n'));
343
- console.log(chalk.dim(' Looked for: ~/.claude/, VS Code agent extension, editor agent configs\n'));
524
+ console.log(chalk.dim(' Looked for: ~/.claude.json, ~/.claude/, VS Code agent extension, editor agent configs\n'));
344
525
  return;
345
526
  }
346
527
  // Show what was found
@@ -650,7 +650,7 @@ a:hover { text-decoration:underline; }
650
650
  <div class="tab" id="tab-markets">
651
651
  <div class="content-header">
652
652
  <h2>Markets</h2>
653
- <p>How Franklin gets trading data — and what it costs.</p>
653
+ <p>How Franklin gets trading + prediction-market data — and what it costs.</p>
654
654
  </div>
655
655
 
656
656
  <div class="grid grid-4">
@@ -2,10 +2,12 @@
2
2
  * Plugin Registry — discovers, loads, and manages plugins.
3
3
  *
4
4
  * Core stays plugin-agnostic: it knows about the *interface*, not specific plugins.
5
- * Plugins are discovered from:
6
- * 1. Bundled: <franklin>/plugins-bundled/* (ships with Franklin)
5
+ * Plugins are discovered from (highest priority first):
6
+ * 1. Local dev: $FRANKLIN_PLUGINS_DIR/* (or legacy $RUNCODE_PLUGINS_DIR/*)
7
7
  * 2. User: ~/.blockrun/plugins/*
8
- * 3. Local dev: $FRANKLIN_PLUGINS_DIR/* (or legacy $RUNCODE_PLUGINS_DIR/*)
8
+ * 3. Bundled: <franklin>/dist/plugins-bundled/* (reserved for plugins
9
+ * shipped inside the npm tarball — none today; social/trading/content
10
+ * are native subsystems, not plugins)
9
11
  */
10
12
  import type { Plugin, PluginManifest } from '../plugin-sdk/plugin.js';
11
13
  export declare function getBundledPluginsDir(): string;
@@ -2,10 +2,12 @@
2
2
  * Plugin Registry — discovers, loads, and manages plugins.
3
3
  *
4
4
  * Core stays plugin-agnostic: it knows about the *interface*, not specific plugins.
5
- * Plugins are discovered from:
6
- * 1. Bundled: <franklin>/plugins-bundled/* (ships with Franklin)
5
+ * Plugins are discovered from (highest priority first):
6
+ * 1. Local dev: $FRANKLIN_PLUGINS_DIR/* (or legacy $RUNCODE_PLUGINS_DIR/*)
7
7
  * 2. User: ~/.blockrun/plugins/*
8
- * 3. Local dev: $FRANKLIN_PLUGINS_DIR/* (or legacy $RUNCODE_PLUGINS_DIR/*)
8
+ * 3. Bundled: <franklin>/dist/plugins-bundled/* (reserved for plugins
9
+ * shipped inside the npm tarball — none today; social/trading/content
10
+ * are native subsystems, not plugins)
9
11
  */
10
12
  import fs from 'node:fs';
11
13
  import path from 'node:path';
@@ -14,8 +16,9 @@ import os from 'node:os';
14
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
17
  // ─── Plugin Discovery Paths ───────────────────────────────────────────────
16
18
  export function getBundledPluginsDir() {
17
- // From dist/plugins/registry.js, plugins-bundled is at ../plugins-bundled
18
- // (built from src/plugins-bundled by tsc + copy-plugin-assets)
19
+ // From dist/plugins/registry.js, look at sibling dist/plugins-bundled/.
20
+ // Empty today the build's copy-plugin-assets script populates it from
21
+ // src/plugins-bundled/ if/when bundled plugins are reintroduced.
19
22
  return path.resolve(__dirname, '..', 'plugins-bundled');
20
23
  }
21
24
  export function getUserPluginsDir() {
@@ -42,6 +42,15 @@ export interface SessionMeta {
42
42
  * add any tool inputs or outputs here, just the count per tool name.
43
43
  */
44
44
  toolCallCounts?: Record<string, number>;
45
+ /**
46
+ * Sessions imported from another agent (`franklin migrate`). Imports often
47
+ * exceed MAX_SESSIONS by an order of magnitude (a Claude Code user can
48
+ * easily have 200+ historical sessions); without this flag, the very
49
+ * next `franklin` launch would prune all but the 20 most recent and
50
+ * silently destroy the user's history. pruneOldSessions() skips any
51
+ * meta with imported=true.
52
+ */
53
+ imported?: true;
45
54
  }
46
55
  /** Get the absolute path to a session's JSONL file (for external readers like search). */
47
56
  export declare function getSessionFilePath(id: string): string;
@@ -132,6 +132,11 @@ export function updateSessionMeta(sessionId, meta) {
132
132
  ...(meta.toolCallCounts !== undefined || existing?.toolCallCounts !== undefined
133
133
  ? { toolCallCounts: meta.toolCallCounts ?? existing?.toolCallCounts }
134
134
  : {}),
135
+ // `imported` is sticky like `chain`: once set by `franklin migrate`
136
+ // it must survive every subsequent update so pruneOldSessions keeps
137
+ // shielding the session from auto-deletion. Without preservation, the
138
+ // first turn added via `--resume` would silently drop the flag.
139
+ ...(meta.imported || existing?.imported ? { imported: true } : {}),
135
140
  };
136
141
  // Atomic write: tmp file + rename. Prevents corruption when parent
137
142
  // and sub-agent update the same session meta concurrently.
@@ -234,10 +239,12 @@ export function findLatestSessionByChannel(channel) {
234
239
  * Accepts optional activeSessionId to protect from deletion.
235
240
  */
236
241
  export function pruneOldSessions(activeSessionId) {
237
- const sessions = readSessionMetas(false);
238
- const allSessions = readSessionMetas(true);
239
- if (sessions.length > MAX_SESSIONS) {
240
- const toDelete = sessions
242
+ // Only count native sessions toward the MAX_SESSIONS budget. Imported
243
+ // sessions (from `franklin migrate`) are user-owned history and must
244
+ // never be auto-deleted just because the user ran the agent again.
245
+ const native = readSessionMetas(false).filter(s => !s.imported);
246
+ if (native.length > MAX_SESSIONS) {
247
+ const toDelete = native
241
248
  .slice(MAX_SESSIONS)
242
249
  .filter(s => s.id !== activeSessionId); // Never delete active session
243
250
  for (const s of toDelete) {
@@ -251,11 +258,16 @@ export function pruneOldSessions(activeSessionId) {
251
258
  catch { /* ok */ }
252
259
  }
253
260
  }
254
- // Also clean up ghost sessions (0 messages, older than 5 minutes)
261
+ // Also clean up ghost sessions (0 messages, older than 5 minutes).
262
+ // Skip imported sessions — they may legitimately have messageCount=0
263
+ // if the source file had only attachments/system lines.
255
264
  const fiveMinAgo = Date.now() - 5 * 60 * 1000;
265
+ const allSessions = readSessionMetas(true);
256
266
  for (const s of allSessions) {
257
267
  if (s.id === activeSessionId)
258
268
  continue;
269
+ if (s.imported)
270
+ continue;
259
271
  if (s.messageCount === 0 && s.createdAt < fiveMinAgo) {
260
272
  try {
261
273
  fs.unlinkSync(sessionPath(s.id));
@@ -1,7 +1,8 @@
1
1
  /**
2
- * PredictionMarket — unified access to Polymarket / Kalshi / cross-platform
3
- * matching / smart-money endpoints via the BlockRun gateway. Each call
4
- * settles via x402 against the user's USDC wallet.
2
+ * PredictionMarket — unified access to Polymarket / Kalshi / Limitless /
3
+ * Opinion / Predict.Fun / cross-platform / smart-money / wallet endpoints
4
+ * via the BlockRun gateway. Each call settles via x402 against the user's
5
+ * USDC wallet.
5
6
  *
6
7
  * Powered server-side by Predexon; surfaced to the agent as a single
7
8
  * action-dispatched tool so the inventory stays small. Keep one cohesive
@@ -9,12 +10,25 @@
9
10
  * one-shot capabilities, otherwise weak models start hallucinating tool
10
11
  * names.
11
12
  *
13
+ * searchAll $0.005 search markets across Polymarket+Kalshi+
14
+ * Limitless+Opinion+Predict.Fun in one call
12
15
  * searchPolymarket $0.001 query Polymarket markets (event filter, sort)
13
16
  * searchKalshi $0.001 query Kalshi markets
14
17
  * crossPlatform $0.005 matching market pairs across Polymarket+Kalshi
15
18
  * (the arbitrage / consensus signal)
16
- * smartMoney $0.005 smart-money positioning on one Polymarket
17
- * condition_id (top wallet flow + side bias)
19
+ * leaderboard $0.001 global Polymarket leaderboard top wallets by P&L
20
+ * walletProfile $0.005 batch profile lookup for one or more Polymarket
21
+ * wallets — P&L, positions, identity
22
+ * smartActivity $0.005 discover markets where high-performing wallets
23
+ * are active right now
24
+ *
25
+ * Replaces the old `smartMoney` action (3.15.69 and earlier) which hit a
26
+ * non-existent path /v1/pm/polymarket/market/<id>/smart-money — that endpoint
27
+ * was never on the gateway, so the action was a silent 404 from day one.
28
+ * Verified 2026-05-05 against blockrun.ai/openapi.json: Polymarket has no
29
+ * per-market path-parameter endpoints; smart-money intelligence lives at
30
+ * /v1/pm/polymarket/markets/smart-activity (cross-market discovery) and
31
+ * /v1/pm/polymarket/leaderboard (top wallets globally).
18
32
  *
19
33
  * Output is filtered + truncated on the way back so a single call never
20
34
  * dumps 100 markets into the agent's context. Default 20 rows; agents that
@@ -1,7 +1,8 @@
1
1
  /**
2
- * PredictionMarket — unified access to Polymarket / Kalshi / cross-platform
3
- * matching / smart-money endpoints via the BlockRun gateway. Each call
4
- * settles via x402 against the user's USDC wallet.
2
+ * PredictionMarket — unified access to Polymarket / Kalshi / Limitless /
3
+ * Opinion / Predict.Fun / cross-platform / smart-money / wallet endpoints
4
+ * via the BlockRun gateway. Each call settles via x402 against the user's
5
+ * USDC wallet.
5
6
  *
6
7
  * Powered server-side by Predexon; surfaced to the agent as a single
7
8
  * action-dispatched tool so the inventory stays small. Keep one cohesive
@@ -9,12 +10,25 @@
9
10
  * one-shot capabilities, otherwise weak models start hallucinating tool
10
11
  * names.
11
12
  *
13
+ * searchAll $0.005 search markets across Polymarket+Kalshi+
14
+ * Limitless+Opinion+Predict.Fun in one call
12
15
  * searchPolymarket $0.001 query Polymarket markets (event filter, sort)
13
16
  * searchKalshi $0.001 query Kalshi markets
14
17
  * crossPlatform $0.005 matching market pairs across Polymarket+Kalshi
15
18
  * (the arbitrage / consensus signal)
16
- * smartMoney $0.005 smart-money positioning on one Polymarket
17
- * condition_id (top wallet flow + side bias)
19
+ * leaderboard $0.001 global Polymarket leaderboard top wallets by P&L
20
+ * walletProfile $0.005 batch profile lookup for one or more Polymarket
21
+ * wallets — P&L, positions, identity
22
+ * smartActivity $0.005 discover markets where high-performing wallets
23
+ * are active right now
24
+ *
25
+ * Replaces the old `smartMoney` action (3.15.69 and earlier) which hit a
26
+ * non-existent path /v1/pm/polymarket/market/<id>/smart-money — that endpoint
27
+ * was never on the gateway, so the action was a silent 404 from day one.
28
+ * Verified 2026-05-05 against blockrun.ai/openapi.json: Polymarket has no
29
+ * per-market path-parameter endpoints; smart-money intelligence lives at
30
+ * /v1/pm/polymarket/markets/smart-activity (cross-market discovery) and
31
+ * /v1/pm/polymarket/leaderboard (top wallets globally).
18
32
  *
19
33
  * Output is filtered + truncated on the way back so a single call never
20
34
  * dumps 100 markets into the agent's context. Default 20 rows; agents that
@@ -23,9 +37,30 @@
23
37
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
24
38
  import { loadChain, API_URLS, VERSION } from '../config.js';
25
39
  import { logger } from '../logger.js';
40
+ import { recordFetch } from '../trading/providers/telemetry.js';
26
41
  const TIMEOUT_MS = 30_000;
27
42
  const DEFAULT_LIMIT = 20;
28
43
  const MAX_LIMIT = 50;
44
+ // Per-action price table — mirrors the Predexon openapi.json. Used to feed
45
+ // the Markets-tab telemetry ring buffer so prediction-market spend appears
46
+ // in "Calls today / Spend today / Recent paid calls" alongside trading calls.
47
+ // If a path isn't here we don't record cost — we still record the fetch
48
+ // (success/latency) so panel health stays accurate.
49
+ const PATH_PRICES = [
50
+ { pattern: /\/v1\/pm\/markets\/search$/, usd: 0.005 },
51
+ { pattern: /\/v1\/pm\/matching-markets/, usd: 0.005 },
52
+ { pattern: /\/v1\/pm\/polymarket\/wallets\//, usd: 0.005 },
53
+ { pattern: /\/v1\/pm\/polymarket\/wallet\//, usd: 0.005 },
54
+ { pattern: /\/v1\/pm\/polymarket\/markets\/smart-activity$/, usd: 0.005 },
55
+ { pattern: /\/v1\/pm\/.+/, usd: 0.001 },
56
+ ];
57
+ function priceForPath(path) {
58
+ for (const { pattern, usd } of PATH_PRICES) {
59
+ if (pattern.test(path))
60
+ return usd;
61
+ }
62
+ return 0;
63
+ }
29
64
  // ─── Shared GET-with-x402 flow ────────────────────────────────────────────
30
65
  async function getWithPayment(path, query, ctx) {
31
66
  const chain = loadChain();
@@ -46,6 +81,8 @@ async function getWithPayment(path, query, ctx) {
46
81
  const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
47
82
  const onAbort = () => controller.abort();
48
83
  ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
84
+ const startedAt = Date.now();
85
+ let costRecorded = 0;
49
86
  try {
50
87
  let response = await fetch(endpoint, { method: 'GET', signal: controller.signal, headers });
51
88
  if (response.status === 402) {
@@ -58,11 +95,23 @@ async function getWithPayment(path, query, ctx) {
58
95
  signal: controller.signal,
59
96
  headers: { ...headers, ...paymentHeaders },
60
97
  });
98
+ // Only record cost on the post-402 settlement; the initial 402
99
+ // response is free and counting it would double-charge the panel.
100
+ costRecorded = priceForPath(path);
61
101
  }
62
102
  if (!response.ok) {
63
103
  const errText = await response.text().catch(() => '');
104
+ // Surface failed paid calls in the Markets-tab health summary.
105
+ recordFetch({ provider: 'blockrun', endpoint: path, ok: false, latencyMs: Date.now() - startedAt });
64
106
  throw new Error(`PredictionMarket ${path} failed (${response.status}): ${errText.slice(0, 200)}`);
65
107
  }
108
+ recordFetch({
109
+ provider: 'blockrun',
110
+ endpoint: path,
111
+ ok: true,
112
+ latencyMs: Date.now() - startedAt,
113
+ costUsd: costRecorded > 0 ? costRecorded : undefined,
114
+ });
66
115
  return (await response.json());
67
116
  }
68
117
  finally {
@@ -155,13 +204,203 @@ function unwrapList(raw) {
155
204
  return [];
156
205
  }
157
206
  async function execute(input, ctx) {
158
- const { action, search, status, sort, limit, conditionId } = input;
207
+ const { action, search, status, sort, limit, wallets } = input;
159
208
  const cappedLimit = Math.min(Math.max(1, limit ?? DEFAULT_LIMIT), MAX_LIMIT);
160
209
  if (!action) {
161
- return { output: 'Error: action is required (searchPolymarket | searchKalshi | crossPlatform | smartMoney)', isError: true };
210
+ return {
211
+ output: 'Error: action is required (searchAll | searchPolymarket | searchKalshi | crossPlatform | leaderboard | walletProfile | smartActivity)',
212
+ isError: true,
213
+ };
162
214
  }
163
215
  try {
164
216
  switch (action) {
217
+ case 'searchAll': {
218
+ // One $0.005 call across 5 platforms — Polymarket, Kalshi, Limitless,
219
+ // Opinion, Predict.Fun. The right entry point for "is there a market
220
+ // on X anywhere?" — beats firing per-platform searches in parallel.
221
+ const raw = await getWithPayment('/v1/pm/markets/search', {
222
+ search,
223
+ status,
224
+ sort,
225
+ limit: cappedLimit,
226
+ }, ctx);
227
+ // Predexon returns either a flat list or per-platform buckets.
228
+ // Try the bucket shape first; fall back to a flat list.
229
+ const lines = [
230
+ `## Cross-platform market search` + (search ? ` · "${search}"` : ''),
231
+ '_Searched Polymarket, Kalshi, Limitless, Opinion, Predict.Fun in one call._',
232
+ '',
233
+ ];
234
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
235
+ const obj = raw;
236
+ const platforms = ['polymarket', 'kalshi', 'limitless', 'opinion', 'predictfun', 'predict_fun'];
237
+ let totalShown = 0;
238
+ for (const p of platforms) {
239
+ const list = unwrapList(obj[p]);
240
+ if (list.length === 0)
241
+ continue;
242
+ const remaining = cappedLimit - totalShown;
243
+ if (remaining <= 0)
244
+ break;
245
+ const shown = list.slice(0, Math.min(5, remaining));
246
+ lines.push(`### ${p}`);
247
+ shown.forEach((m, i) => {
248
+ const title = (m.title || m.question || m.market_slug || m.ticker || 'untitled');
249
+ const id = (m.condition_id || m.ticker || m.id);
250
+ const idTag = id ? ` · \`${String(id).slice(0, 18)}…\`` : '';
251
+ const vol = m.volume != null ? ` · vol ${formatUsd(m.volume)}` : '';
252
+ lines.push(`${i + 1}. ${title}${idTag}${vol}`);
253
+ totalShown++;
254
+ });
255
+ lines.push('');
256
+ }
257
+ if (totalShown === 0) {
258
+ // Bucket shape but empty — fall back to flat-list interpretation.
259
+ const flat = unwrapList(raw);
260
+ if (flat.length === 0) {
261
+ return { output: 'No markets matched across any platform.' };
262
+ }
263
+ flat.slice(0, cappedLimit).forEach((m, i) => {
264
+ const title = (m.title || m.question || m.market_slug || m.ticker || 'untitled');
265
+ const platform = (m.platform || m.source || 'unknown');
266
+ lines.push(`${i + 1}. **[${platform}]** ${title}`);
267
+ });
268
+ }
269
+ }
270
+ else {
271
+ const flat = unwrapList(raw);
272
+ if (flat.length === 0) {
273
+ return { output: 'No markets matched across any platform.' };
274
+ }
275
+ flat.slice(0, cappedLimit).forEach((m, i) => {
276
+ const title = (m.title || m.question || m.market_slug || m.ticker || 'untitled');
277
+ const platform = (m.platform || m.source || 'unknown');
278
+ lines.push(`${i + 1}. **[${platform}]** ${title}`);
279
+ });
280
+ }
281
+ lines.push(`_$0.005 paid via x402._`);
282
+ return { output: lines.join('\n') };
283
+ }
284
+ case 'leaderboard': {
285
+ // Global top-wallet ranking. Cheap ($0.001) — the right answer to
286
+ // "who's making money on Polymarket" / "who should I follow".
287
+ const raw = await getWithPayment('/v1/pm/polymarket/leaderboard', {
288
+ limit: cappedLimit,
289
+ sort,
290
+ }, ctx);
291
+ const rows = unwrapList(raw);
292
+ if (rows.length === 0) {
293
+ return { output: 'No leaderboard data returned.' };
294
+ }
295
+ const lines = [
296
+ `## Polymarket leaderboard — top ${rows.length} wallet${rows.length === 1 ? '' : 's'}`,
297
+ '',
298
+ ];
299
+ rows.forEach((r, i) => {
300
+ const wallet = (r.wallet || r.address || r.proxy_wallet || 'unknown');
301
+ const w = wallet.length > 12
302
+ ? `${wallet.slice(0, 8)}…${wallet.slice(-4)}`
303
+ : wallet;
304
+ const pnl = r.pnl ?? r.realized_pnl ?? r.total_pnl;
305
+ const volume = r.volume ?? r.total_volume;
306
+ const winRate = r.win_rate ?? r.winRate;
307
+ const name = (r.name || r.handle || r.username);
308
+ const handle = name ? ` (${name})` : '';
309
+ const parts = [];
310
+ if (pnl != null)
311
+ parts.push(`P&L ${formatUsd(pnl)}`);
312
+ if (volume != null)
313
+ parts.push(`vol ${formatUsd(volume)}`);
314
+ if (winRate != null)
315
+ parts.push(`win ${formatPct(winRate, 0)}`);
316
+ lines.push(`${i + 1}. \`${w}\`${handle}` + (parts.length > 0 ? ` — ${parts.join(' · ')}` : ''));
317
+ });
318
+ lines.push('', `_$0.001 paid via x402._`);
319
+ return { output: lines.join('\n') };
320
+ }
321
+ case 'walletProfile': {
322
+ if (!wallets || !wallets.trim()) {
323
+ return {
324
+ output: 'Error: `wallets` is required for walletProfile (single address or comma-separated list of Polymarket wallet addresses)',
325
+ isError: true,
326
+ };
327
+ }
328
+ // Predexon's batch endpoint accepts multiple wallets; we forward
329
+ // verbatim. Single wallet works too — caller passes one address.
330
+ const raw = await getWithPayment('/v1/pm/polymarket/wallets/profiles', {
331
+ wallets: wallets.trim(),
332
+ }, ctx);
333
+ const profiles = unwrapList(raw);
334
+ if (profiles.length === 0) {
335
+ return { output: `No profile data returned for: ${wallets}` };
336
+ }
337
+ const lines = [
338
+ `## Polymarket wallet profile${profiles.length === 1 ? '' : 's'} — ${profiles.length}`,
339
+ '',
340
+ ];
341
+ profiles.forEach((p, i) => {
342
+ const wallet = (p.wallet || p.address || p.proxy_wallet || 'unknown');
343
+ const w = wallet.length > 12
344
+ ? `${wallet.slice(0, 8)}…${wallet.slice(-4)}`
345
+ : wallet;
346
+ const name = (p.name || p.handle || p.username);
347
+ const pnl = p.pnl ?? p.realized_pnl ?? p.total_pnl;
348
+ const unrealized = p.unrealized_pnl;
349
+ const volume = p.volume ?? p.total_volume;
350
+ const positions = p.positions_count ?? p.open_positions;
351
+ const winRate = p.win_rate ?? p.winRate;
352
+ lines.push(`${i + 1}. \`${w}\`` + (name ? ` (${name})` : ''));
353
+ const stats = [];
354
+ if (pnl != null)
355
+ stats.push(`P&L ${formatUsd(pnl)}`);
356
+ if (unrealized != null)
357
+ stats.push(`unrealized ${formatUsd(unrealized)}`);
358
+ if (volume != null)
359
+ stats.push(`vol ${formatUsd(volume)}`);
360
+ if (positions != null)
361
+ stats.push(`${positions} open`);
362
+ if (winRate != null)
363
+ stats.push(`win ${formatPct(winRate, 0)}`);
364
+ if (stats.length > 0)
365
+ lines.push(` ${stats.join(' · ')}`);
366
+ });
367
+ lines.push('', `_$0.005 paid via x402._`);
368
+ return { output: lines.join('\n') };
369
+ }
370
+ case 'smartActivity': {
371
+ // "Discover markets where high-performing wallets are active right now."
372
+ // Replaces the old `smartMoney` action (which hit a non-existent path
373
+ // /v1/pm/polymarket/market/<id>/smart-money — silently 404'd from
374
+ // launch). Verified 2026-05-05 against blockrun.ai/openapi.json.
375
+ const raw = await getWithPayment('/v1/pm/polymarket/markets/smart-activity', {
376
+ limit: cappedLimit,
377
+ search,
378
+ }, ctx);
379
+ const rows = unwrapList(raw);
380
+ if (rows.length === 0) {
381
+ return { output: 'No smart-money activity recorded right now.' };
382
+ }
383
+ const lines = [
384
+ `## Smart-money activity — ${rows.length} market${rows.length === 1 ? '' : 's'}`,
385
+ '_Markets where high-P&L Polymarket wallets are positioning right now._',
386
+ '',
387
+ ];
388
+ rows.forEach((r, i) => {
389
+ const title = (r.question || r.title || r.market_slug || 'untitled');
390
+ const cid = (r.condition_id || r.id);
391
+ const cidTag = cid ? ` · \`${String(cid).slice(0, 14)}…\`` : '';
392
+ const smartCount = r.smart_wallets_count ?? r.wallet_count;
393
+ const netFlow = r.net_size ?? r.net_yes_size;
394
+ const stats = [];
395
+ if (smartCount != null)
396
+ stats.push(`${smartCount} smart wallet${smartCount === 1 ? '' : 's'}`);
397
+ if (netFlow != null)
398
+ stats.push(`net ${formatUsd(netFlow)}`);
399
+ lines.push(`${i + 1}. **${title}**${cidTag}` + (stats.length > 0 ? `\n ${stats.join(' · ')}` : ''));
400
+ });
401
+ lines.push('', `_$0.005 paid via x402._`);
402
+ return { output: lines.join('\n') };
403
+ }
165
404
  case 'searchPolymarket': {
166
405
  const raw = await getWithPayment('/v1/pm/polymarket/markets', {
167
406
  search,
@@ -245,45 +484,9 @@ async function execute(input, ctx) {
245
484
  lines.push('', `_$0.005 paid via x402._`);
246
485
  return { output: lines.join('\n') };
247
486
  }
248
- case 'smartMoney': {
249
- if (!conditionId) {
250
- return { output: 'Error: conditionId is required for smartMoney (Polymarket condition_id from a prior searchPolymarket call)', isError: true };
251
- }
252
- const path = `/v1/pm/polymarket/market/${encodeURIComponent(conditionId)}/smart-money`;
253
- const data = await getWithPayment(path, {}, ctx);
254
- const buyers = (data.buyers ?? []).slice(0, 5);
255
- const sellers = (data.sellers ?? []).slice(0, 5);
256
- const lines = [
257
- `## Smart money — \`${conditionId.slice(0, 14)}…\``,
258
- ];
259
- if (data.net_yes_size != null || data.net_no_size != null) {
260
- const yesSize = formatUsd(data.net_yes_size);
261
- const noSize = formatUsd(data.net_no_size);
262
- lines.push(`**Net flow:** YES ${yesSize} / NO ${noSize}`);
263
- }
264
- if (buyers.length > 0) {
265
- lines.push('', '**Top buyers**');
266
- buyers.forEach((b, i) => {
267
- const w = b.wallet ? `${b.wallet.slice(0, 8)}…${b.wallet.slice(-4)}` : 'unknown';
268
- lines.push(`${i + 1}. ${w} — ${formatUsd(b.size)} on ${b.outcome ?? 'unknown side'}`);
269
- });
270
- }
271
- if (sellers.length > 0) {
272
- lines.push('', '**Top sellers**');
273
- sellers.forEach((s, i) => {
274
- const w = s.wallet ? `${s.wallet.slice(0, 8)}…${s.wallet.slice(-4)}` : 'unknown';
275
- lines.push(`${i + 1}. ${w} — ${formatUsd(s.size)} on ${s.outcome ?? 'unknown side'}`);
276
- });
277
- }
278
- if (buyers.length === 0 && sellers.length === 0) {
279
- lines.push('No smart-money flow recorded for this market yet.');
280
- }
281
- lines.push('', `_$0.005 paid via x402._`);
282
- return { output: lines.join('\n') };
283
- }
284
487
  default:
285
488
  return {
286
- output: `Error: unknown action "${action}". Use: searchPolymarket, searchKalshi, crossPlatform, smartMoney`,
489
+ output: `Error: unknown action "${action}". Use: searchAll, searchPolymarket, searchKalshi, crossPlatform, leaderboard, walletProfile, smartActivity`,
287
490
  isError: true,
288
491
  };
289
492
  }
@@ -295,41 +498,56 @@ async function execute(input, ctx) {
295
498
  export const predictionMarketCapability = {
296
499
  spec: {
297
500
  name: 'PredictionMarket',
298
- description: 'Real prediction market data via the BlockRun gateway (powered by Predexon). Use for any "what are the odds of X" / "Polymarket on Y" / "Kalshi market for Z" question. ' +
501
+ description: 'Real prediction market data via the BlockRun gateway (powered by Predexon). Use for any "what are the odds of X" / "Polymarket on Y" / "is there a market on Z" / "follow this trader" question. ' +
299
502
  'Actions: ' +
300
- '`searchPolymarket` (search Polymarket markets — $0.001), ' +
301
- '`searchKalshi` (search Kalshi markets — $0.001), ' +
302
- '`crossPlatform` (matched pairs across Polymarket+Kalshi for arbitrage / consensus — $0.005), ' +
303
- '`smartMoney` (smart-money positioning on a Polymarket condition_id — $0.005). ' +
304
- 'Polymarket and Kalshi are the two largest legit prediction markets; cross-platform matching is unique to BlockRun. ' +
305
- 'For "should I bet on X" / "what does the market price imply": run searchPolymarket AND searchKalshi in parallel and compare implied probabilities divergence is the signal.',
503
+ '`searchAll` (search markets across Polymarket+Kalshi+Limitless+Opinion+Predict.Fun in one call — $0.005), ' +
504
+ '`searchPolymarket` (Polymarket only, supports sort+status — $0.001), ' +
505
+ '`searchKalshi` (Kalshi only, supports sort+status — $0.001), ' +
506
+ '`crossPlatform` (matched market pairs across Polymarket+Kalshi for arbitrage / consensus — $0.005), ' +
507
+ '`leaderboard` (global top wallets by P&L on Polymarket $0.001), ' +
508
+ '`walletProfile` (P&L + positions for one or more Polymarket wallets$0.005), ' +
509
+ '`smartActivity` (markets where high-P&L wallets are positioning right now — $0.005). ' +
510
+ 'Default routing: ' +
511
+ '"is there a market on X anywhere" → searchAll. ' +
512
+ '"top wallets / who is profitable / who should I follow on Polymarket" → leaderboard. ' +
513
+ '"how is wallet 0xabc doing / show me their P&L" → walletProfile with that address. ' +
514
+ '"what are smart traders betting on right now" → smartActivity. ' +
515
+ '"should I bet on X" → run searchPolymarket + searchKalshi in parallel and compare implied probabilities — divergence is the signal.',
306
516
  input_schema: {
307
517
  type: 'object',
308
518
  properties: {
309
519
  action: {
310
520
  type: 'string',
311
- enum: ['searchPolymarket', 'searchKalshi', 'crossPlatform', 'smartMoney'],
521
+ enum: [
522
+ 'searchAll',
523
+ 'searchPolymarket',
524
+ 'searchKalshi',
525
+ 'crossPlatform',
526
+ 'leaderboard',
527
+ 'walletProfile',
528
+ 'smartActivity',
529
+ ],
312
530
  description: 'Which prediction-market query to run. See tool description for cost per action.',
313
531
  },
314
532
  search: {
315
533
  type: 'string',
316
- description: 'Search query (3-100 chars). Used by searchPolymarket / searchKalshi. Skip for crossPlatform/smartMoney.',
534
+ description: 'Search query. Used by searchAll / searchPolymarket / searchKalshi / smartActivity. Optional for crossPlatform/leaderboard/walletProfile.',
317
535
  },
318
536
  status: {
319
537
  type: 'string',
320
- description: 'Polymarket: active | closed | archived (default active). Kalshi: open | closed (default open).',
538
+ description: 'Polymarket: active | closed | archived (default active). Kalshi: open | closed (default open). Forwarded to searchAll where supported.',
321
539
  },
322
540
  sort: {
323
541
  type: 'string',
324
- description: 'Polymarket: volume | liquidity | created (default volume). Kalshi: volume | open_interest | price_desc | price_asc | close_time (default volume).',
542
+ description: 'Polymarket: volume | liquidity | created (default volume). Kalshi: volume | open_interest | price_desc | price_asc | close_time (default volume). leaderboard: pnl | volume | win_rate (gateway-defined).',
325
543
  },
326
544
  limit: {
327
545
  type: 'number',
328
546
  description: `Max results (default ${DEFAULT_LIMIT}, hard cap ${MAX_LIMIT}).`,
329
547
  },
330
- conditionId: {
548
+ wallets: {
331
549
  type: 'string',
332
- description: 'Polymarket condition_id. Required for smartMoney. Get one from a prior searchPolymarket call.',
550
+ description: 'For walletProfile: a single Polymarket wallet address, or a comma-separated list of addresses for batch lookup.',
333
551
  },
334
552
  },
335
553
  required: ['action'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.68",
3
+ "version": "3.15.70",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {