@blockrun/franklin 3.23.0 → 3.24.0
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/error-classifier.js +13 -2
- package/dist/agent/loop.js +34 -7
- package/dist/commands/start.js +37 -2
- package/dist/mcp/client.d.ts +11 -0
- package/dist/mcp/client.js +32 -1
- package/dist/mcp/codegraph.d.ts +45 -0
- package/dist/mcp/codegraph.js +105 -0
- package/dist/mcp/config.js +8 -0
- package/package.json +2 -1
- package/dist/tools/prediction.debug.js +0 -828
|
@@ -30,6 +30,17 @@ export function classifyAgentError(message) {
|
|
|
30
30
|
// `Exa /v1/exa/search failed (402): {"error":"Payment verification failed",...}`.
|
|
31
31
|
// Classify BEFORE the generic 'payment' branch below since the body
|
|
32
32
|
// contains both 'payment' and 'verification failed'.
|
|
33
|
+
//
|
|
34
|
+
// Treated as transient with a small retry budget: real-world telemetry
|
|
35
|
+
// (2026-05-28 audit) shows the gateway intermittently rejects valid
|
|
36
|
+
// signed payments under burst load — identical prompts succeed 5s
|
|
37
|
+
// later. Most plausible root cause is a nonce-cache race in the
|
|
38
|
+
// gateway's replay protection. Retrying re-signs with a fresh nonce on
|
|
39
|
+
// each attempt (llm.ts derives a new nonce per request), so a retry
|
|
40
|
+
// is NOT a replay. Three attempts is enough to ride out the blip
|
|
41
|
+
// without burning tokens on a model whose wallet is genuinely
|
|
42
|
+
// misconfigured (clock skew, wrong chain) — those failure modes are
|
|
43
|
+
// deterministic and will exhaust the budget quickly.
|
|
33
44
|
if (includesAny(err, [
|
|
34
45
|
'verification failed',
|
|
35
46
|
'payment verification',
|
|
@@ -40,8 +51,8 @@ export function classifyAgentError(message) {
|
|
|
40
51
|
'replay protection',
|
|
41
52
|
])) {
|
|
42
53
|
return {
|
|
43
|
-
category: 'payment_rejected', label: 'PaymentRejected', isTransient:
|
|
44
|
-
suggestion: 'The gateway rejected your signed payment.
|
|
54
|
+
category: 'payment_rejected', label: 'PaymentRejected', isTransient: true, maxRetries: 3,
|
|
55
|
+
suggestion: 'The gateway rejected your signed payment. If this keeps happening: run `franklin balance` to confirm funds + chain. Common causes: clock skew (resync system clock), wrong chain selected (use `/chain` to switch). Transient blips are auto-retried.',
|
|
45
56
|
};
|
|
46
57
|
}
|
|
47
58
|
if (includesAny(err, [
|
package/dist/agent/loop.js
CHANGED
|
@@ -1539,13 +1539,21 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1539
1539
|
continue;
|
|
1540
1540
|
}
|
|
1541
1541
|
// ── Payment failure: auto-fallback to free models ──
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
|
|
1542
|
+
// 'payment' (insufficient funds / 402): session-permanent blacklist —
|
|
1543
|
+
// the wallet won't refill mid-session, so retrying the same model
|
|
1544
|
+
// just wastes a turn. Record to elo so the router learns to avoid it.
|
|
1545
|
+
//
|
|
1546
|
+
// 'payment_rejected' (signed payment rejected by gateway): only
|
|
1547
|
+
// fall back FOR THIS TURN — do NOT add to paymentFailedModels and
|
|
1548
|
+
// do NOT record to elo. The retry budget from the transient path
|
|
1549
|
+
// above (3 attempts) has already been exhausted at this point;
|
|
1550
|
+
// this fallback just lets the user keep working. The next user
|
|
1551
|
+
// turn resets to baseModel (see top of outer loop) so a single
|
|
1552
|
+
// gateway nonce-race blip can't permanently demote the user to
|
|
1553
|
+
// free models for the whole session — that's the bug audited
|
|
1554
|
+
// 2026-05-28 from telemetry showing 28/468 PaymentRejected with
|
|
1555
|
+
// identical prompts succeeding 5s apart.
|
|
1556
|
+
if (classified.category === 'payment') {
|
|
1549
1557
|
turnFailedModels.add(config.model);
|
|
1550
1558
|
paymentFailedModels.set(config.model, Date.now());
|
|
1551
1559
|
// Bound the Map so long sessions don't leak. LRU-evict oldest by timestamp.
|
|
@@ -1571,6 +1579,25 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1571
1579
|
continue; // Retry with next model
|
|
1572
1580
|
}
|
|
1573
1581
|
}
|
|
1582
|
+
if (classified.category === 'payment_rejected') {
|
|
1583
|
+
turnFailedModels.add(config.model);
|
|
1584
|
+
const nextFree = pickFreeFallback(lastRoutedCategory, turnFailedModels);
|
|
1585
|
+
if (nextFree) {
|
|
1586
|
+
const oldModel = config.model;
|
|
1587
|
+
config.model = nextFree;
|
|
1588
|
+
config.onModelChange?.(nextFree, 'system');
|
|
1589
|
+
const reason = `gateway rejected payment [${classified.label}] — will retry ${oldModel} next turn`;
|
|
1590
|
+
// Reset retry counter — the transient path above already burned
|
|
1591
|
+
// this turn's budget on the rejected model; the free fallback
|
|
1592
|
+
// model gets its own (mirrors the rate_limit fallback below).
|
|
1593
|
+
recoveryAttempts = 0;
|
|
1594
|
+
onEvent({
|
|
1595
|
+
kind: 'text_delta',
|
|
1596
|
+
text: `\n*${formatModelSwitch(oldModel, resolvedModel, reason, nextFree)}*\n`,
|
|
1597
|
+
});
|
|
1598
|
+
continue; // Retry with next model
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1574
1601
|
// ── Rate-limit / quota: auto-fallback to a different provider ──
|
|
1575
1602
|
// Per-day TPM caps (Anthropic) won't clear in this session; per-second
|
|
1576
1603
|
// limits already had their backoff retry above and still failed. In
|
package/dist/commands/start.js
CHANGED
|
@@ -15,7 +15,8 @@ import { validateToolDescriptions } from '../tools/validate.js';
|
|
|
15
15
|
import { launchInkUI } from '../ui/app.js';
|
|
16
16
|
import { pickModel, resolveModel } from '../ui/model-picker.js';
|
|
17
17
|
import { loadMcpConfig } from '../mcp/config.js';
|
|
18
|
-
import { connectMcpServers, disconnectMcpServers } from '../mcp/client.js';
|
|
18
|
+
import { connectMcpServers, disconnectMcpServers, getMcpServerInstructions } from '../mcp/client.js';
|
|
19
|
+
import { ensureCodegraphIndex } from '../mcp/codegraph.js';
|
|
19
20
|
export async function startCommand(options) {
|
|
20
21
|
const version = options.version ?? '1.0.0';
|
|
21
22
|
// Early-validate explicit resume ID so a typo fails fast — before wallet
|
|
@@ -282,6 +283,10 @@ export async function startCommand(options) {
|
|
|
282
283
|
const systemInstructions = assembleInstructions(workDir, model);
|
|
283
284
|
// Connect MCP servers (non-blocking — add tools if servers are available)
|
|
284
285
|
const mcpConfig = loadMcpConfig(workDir);
|
|
286
|
+
// Kick off the CodeGraph index build (no-op if disabled/absent/already built).
|
|
287
|
+
// Runs in the background so it never blocks startup; the agent falls back to
|
|
288
|
+
// grep/read until the index is ready.
|
|
289
|
+
ensureCodegraphIndex(workDir);
|
|
285
290
|
let mcpTools = [];
|
|
286
291
|
const mcpServerCount = Object.keys(mcpConfig.mcpServers).filter(k => !mcpConfig.mcpServers[k].disabled).length;
|
|
287
292
|
if (mcpServerCount > 0) {
|
|
@@ -290,6 +295,13 @@ export async function startCommand(options) {
|
|
|
290
295
|
if (mcpTools.length > 0) {
|
|
291
296
|
console.log(chalk.dim(` MCP: ${mcpTools.length} tools from ${mcpServerCount} server(s)`));
|
|
292
297
|
}
|
|
298
|
+
// Fold each connected server's playbook (from its initialize response)
|
|
299
|
+
// into the system prompt. For CodeGraph this is what drives the agent to
|
|
300
|
+
// query the index instead of looping grep — the bulk of the savings.
|
|
301
|
+
const mcpInstructions = getMcpServerInstructions();
|
|
302
|
+
if (mcpInstructions) {
|
|
303
|
+
systemInstructions.push(mcpInstructions);
|
|
304
|
+
}
|
|
293
305
|
}
|
|
294
306
|
catch (err) {
|
|
295
307
|
if (options.debug) {
|
|
@@ -516,7 +528,15 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
|
|
|
516
528
|
if (process.env.FRANKLIN_EXTRACT_ON_EXIT === '1') {
|
|
517
529
|
runExitBackgroundTasks(sessionHistory, agentConfig).catch(() => { });
|
|
518
530
|
}
|
|
519
|
-
|
|
531
|
+
// Await MCP shutdown with a bounded timeout — previously fire-and-forget,
|
|
532
|
+
// which left stdio child processes alive and (combined with no explicit
|
|
533
|
+
// process.exit() below) was the root cause of the "I quit but the
|
|
534
|
+
// process is still running" report (audited 2026-05-28). A misbehaving
|
|
535
|
+
// MCP server must not be able to pin shutdown, so cap the wait at 2s.
|
|
536
|
+
await Promise.race([
|
|
537
|
+
disconnectMcpServers().catch(() => { }),
|
|
538
|
+
new Promise((r) => setTimeout(r, 2000)),
|
|
539
|
+
]);
|
|
520
540
|
// Session summary — delta vs. snapshot at session start
|
|
521
541
|
try {
|
|
522
542
|
const delta = statsDelta(startSnapshot);
|
|
@@ -547,6 +567,14 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
|
|
|
547
567
|
console.log(chalk.dim(' Latest: franklin --continue'));
|
|
548
568
|
}
|
|
549
569
|
console.log(chalk.dim('\nGoodbye.\n'));
|
|
570
|
+
// Explicit exit. Without this, lingering keep-alive sockets (bootstrap
|
|
571
|
+
// learnings importer, panel HTTP server, gateway client agents) and any
|
|
572
|
+
// FRANKLIN_EXTRACT_ON_EXIT background promise can hold the event loop
|
|
573
|
+
// open for seconds-to-minutes after the UI tears down — the user sees
|
|
574
|
+
// "Goodbye." but `ps` still shows the process, and a subsequent
|
|
575
|
+
// `franklin` invocation races with the zombie. Force a clean exit. Any
|
|
576
|
+
// explicit error paths above set process.exitCode = 1 — preserve it.
|
|
577
|
+
process.exit(process.exitCode ?? 0);
|
|
550
578
|
}
|
|
551
579
|
async function runExitBackgroundTasks(sessionHistory, agentConfig) {
|
|
552
580
|
if (!sessionHistory || sessionHistory.length < 4)
|
|
@@ -631,6 +659,13 @@ async function runWithBasicUI(agentConfig, model, workDir, initialInput) {
|
|
|
631
659
|
catch { /* stats unavailable */ }
|
|
632
660
|
ui.printGoodbye();
|
|
633
661
|
flushStats();
|
|
662
|
+
// Same explicit-exit reasoning as runWithInkUI — bounded MCP shutdown
|
|
663
|
+
// then hard exit so background promises can't pin the process alive.
|
|
664
|
+
await Promise.race([
|
|
665
|
+
disconnectMcpServers().catch(() => { }),
|
|
666
|
+
new Promise((r) => setTimeout(r, 2000)),
|
|
667
|
+
]);
|
|
668
|
+
process.exit(process.exitCode ?? 0);
|
|
634
669
|
}
|
|
635
670
|
// ─── Panel auto-start ──────────────────────────────────────────────────────
|
|
636
671
|
async function startPanelBackground(startPort) {
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -34,6 +34,17 @@ export declare function connectMcpServers(config: McpConfig, debug?: boolean): P
|
|
|
34
34
|
* Disconnect all MCP servers.
|
|
35
35
|
*/
|
|
36
36
|
export declare function disconnectMcpServers(): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Aggregate server-level instructions from all connected MCP servers into a
|
|
39
|
+
* single system-prompt section, or '' if no server supplied any.
|
|
40
|
+
*
|
|
41
|
+
* These come from the `initialize` response of servers Franklin chose to
|
|
42
|
+
* connect (built-in, or user-configured + trusted), so they're treated as
|
|
43
|
+
* trusted guidance rather than untrusted data. The agent reads this once per
|
|
44
|
+
* session to learn each toolset's playbook (which tool for which question,
|
|
45
|
+
* common chains, anti-patterns) instead of rediscovering it by trial.
|
|
46
|
+
*/
|
|
47
|
+
export declare function getMcpServerInstructions(): string;
|
|
37
48
|
/**
|
|
38
49
|
* List connected MCP servers and their tools.
|
|
39
50
|
*/
|
package/dist/mcp/client.js
CHANGED
|
@@ -151,7 +151,12 @@ async function connectStdio(name, config) {
|
|
|
151
151
|
catch {
|
|
152
152
|
// Server doesn't support resources — that's fine, tools-only mode
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
// Server-level instructions from the initialize response. MCP servers use
|
|
155
|
+
// this to tell the agent HOW to use their tools (selection-by-intent, common
|
|
156
|
+
// chains, anti-patterns) — e.g. CodeGraph's "answer directly, don't grep to
|
|
157
|
+
// re-verify" playbook, which is where most of its tool-call savings come from.
|
|
158
|
+
const instructions = (client.getInstructions() || '').trim() || undefined;
|
|
159
|
+
const connected = { name, client, transport, tools: capabilities, instructions };
|
|
155
160
|
connections.set(name, connected);
|
|
156
161
|
return connected;
|
|
157
162
|
}
|
|
@@ -210,6 +215,32 @@ export async function disconnectMcpServers() {
|
|
|
210
215
|
connections.delete(name);
|
|
211
216
|
}
|
|
212
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Aggregate server-level instructions from all connected MCP servers into a
|
|
220
|
+
* single system-prompt section, or '' if no server supplied any.
|
|
221
|
+
*
|
|
222
|
+
* These come from the `initialize` response of servers Franklin chose to
|
|
223
|
+
* connect (built-in, or user-configured + trusted), so they're treated as
|
|
224
|
+
* trusted guidance rather than untrusted data. The agent reads this once per
|
|
225
|
+
* session to learn each toolset's playbook (which tool for which question,
|
|
226
|
+
* common chains, anti-patterns) instead of rediscovering it by trial.
|
|
227
|
+
*/
|
|
228
|
+
export function getMcpServerInstructions() {
|
|
229
|
+
const blocks = [];
|
|
230
|
+
for (const [name, conn] of connections) {
|
|
231
|
+
if (conn.instructions) {
|
|
232
|
+
blocks.push(`### MCP server: ${name}\n${conn.instructions}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (blocks.length === 0)
|
|
236
|
+
return '';
|
|
237
|
+
return [
|
|
238
|
+
'## Connected MCP tool playbooks',
|
|
239
|
+
'Each connected MCP server below provides guidance on how to use its tools effectively. Follow these playbooks when those tools are relevant.',
|
|
240
|
+
'',
|
|
241
|
+
blocks.join('\n\n'),
|
|
242
|
+
].join('\n');
|
|
243
|
+
}
|
|
213
244
|
/**
|
|
214
245
|
* List connected MCP servers and their tools.
|
|
215
246
|
*/
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeGraph built-in MCP server integration.
|
|
3
|
+
*
|
|
4
|
+
* CodeGraph (https://github.com/colbymchenry/codegraph, MIT) builds a local
|
|
5
|
+
* SQLite knowledge graph of a repo's symbols, call edges, and files via
|
|
6
|
+
* tree-sitter, then serves it over MCP. For Franklin this is a direct USDC win:
|
|
7
|
+
* agents answer "how does X work / what calls Y / trace this flow" from the
|
|
8
|
+
* pre-built index instead of looping grep + read, which cuts tool calls (and
|
|
9
|
+
* therefore paid LLM round-trips) sharply on real codebases.
|
|
10
|
+
*
|
|
11
|
+
* Shipped as a dependency, so `franklin` users get it with no extra install.
|
|
12
|
+
* The npm package is a thin shim (`npm-shim.js`) that locates a per-platform
|
|
13
|
+
* bundle (vendored Node 24 + app) and execs it — so we always launch it via
|
|
14
|
+
* the user's own node against the shim, never a global `codegraph` on PATH.
|
|
15
|
+
*
|
|
16
|
+
* Opt out with FRANKLIN_CODEGRAPH=0 (or "false").
|
|
17
|
+
*/
|
|
18
|
+
import type { McpServerConfig } from './client.js';
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the CodeGraph npm shim entry point, or null if the dependency
|
|
21
|
+
* isn't installed / resolvable. The shim is plain JS runnable by any node.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveCodegraphShim(): string | null;
|
|
24
|
+
/** Whether CodeGraph is available and not disabled. */
|
|
25
|
+
export declare function isCodegraphEnabled(): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Build the built-in MCP server config for CodeGraph, pinned to `workDir`.
|
|
28
|
+
*
|
|
29
|
+
* We launch via the user's node + the shim (the shim re-execs the bundled
|
|
30
|
+
* Node 24 runtime internally). `--path` is required because Franklin's MCP
|
|
31
|
+
* client doesn't advertise a `roots` capability, so the server can't infer
|
|
32
|
+
* the project from a rootUri — without it CodeGraph wouldn't know which repo
|
|
33
|
+
* to index. Returns null when CodeGraph is unavailable or disabled.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getCodegraphServerConfig(workDir: string): McpServerConfig | null;
|
|
36
|
+
/**
|
|
37
|
+
* Build the initial index for `workDir` if it has no `.codegraph/` yet.
|
|
38
|
+
*
|
|
39
|
+
* Non-blocking: spawns `codegraph init <workDir> -i` detached and returns
|
|
40
|
+
* immediately. The serving MCP process watches the project, so it picks up
|
|
41
|
+
* the freshly built index; until it's ready, codegraph tools report
|
|
42
|
+
* "not initialized" and the agent falls back to grep/read (no regression).
|
|
43
|
+
* No-op when CodeGraph is disabled, unavailable, or already initialized.
|
|
44
|
+
*/
|
|
45
|
+
export declare function ensureCodegraphIndex(workDir: string): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeGraph built-in MCP server integration.
|
|
3
|
+
*
|
|
4
|
+
* CodeGraph (https://github.com/colbymchenry/codegraph, MIT) builds a local
|
|
5
|
+
* SQLite knowledge graph of a repo's symbols, call edges, and files via
|
|
6
|
+
* tree-sitter, then serves it over MCP. For Franklin this is a direct USDC win:
|
|
7
|
+
* agents answer "how does X work / what calls Y / trace this flow" from the
|
|
8
|
+
* pre-built index instead of looping grep + read, which cuts tool calls (and
|
|
9
|
+
* therefore paid LLM round-trips) sharply on real codebases.
|
|
10
|
+
*
|
|
11
|
+
* Shipped as a dependency, so `franklin` users get it with no extra install.
|
|
12
|
+
* The npm package is a thin shim (`npm-shim.js`) that locates a per-platform
|
|
13
|
+
* bundle (vendored Node 24 + app) and execs it — so we always launch it via
|
|
14
|
+
* the user's own node against the shim, never a global `codegraph` on PATH.
|
|
15
|
+
*
|
|
16
|
+
* Opt out with FRANKLIN_CODEGRAPH=0 (or "false").
|
|
17
|
+
*/
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { logger } from '../logger.js';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
/** True unless the user explicitly disabled CodeGraph via env. */
|
|
25
|
+
function userEnabled() {
|
|
26
|
+
const v = (process.env.FRANKLIN_CODEGRAPH || '').toLowerCase();
|
|
27
|
+
return v !== '0' && v !== 'false' && v !== 'off';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the CodeGraph npm shim entry point, or null if the dependency
|
|
31
|
+
* isn't installed / resolvable. The shim is plain JS runnable by any node.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveCodegraphShim() {
|
|
34
|
+
try {
|
|
35
|
+
const shim = require.resolve('@colbymchenry/codegraph/npm-shim.js');
|
|
36
|
+
return fs.existsSync(shim) ? shim : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Whether CodeGraph is available and not disabled. */
|
|
43
|
+
export function isCodegraphEnabled() {
|
|
44
|
+
return userEnabled() && resolveCodegraphShim() !== null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build the built-in MCP server config for CodeGraph, pinned to `workDir`.
|
|
48
|
+
*
|
|
49
|
+
* We launch via the user's node + the shim (the shim re-execs the bundled
|
|
50
|
+
* Node 24 runtime internally). `--path` is required because Franklin's MCP
|
|
51
|
+
* client doesn't advertise a `roots` capability, so the server can't infer
|
|
52
|
+
* the project from a rootUri — without it CodeGraph wouldn't know which repo
|
|
53
|
+
* to index. Returns null when CodeGraph is unavailable or disabled.
|
|
54
|
+
*/
|
|
55
|
+
export function getCodegraphServerConfig(workDir) {
|
|
56
|
+
if (!userEnabled())
|
|
57
|
+
return null;
|
|
58
|
+
const shim = resolveCodegraphShim();
|
|
59
|
+
if (!shim)
|
|
60
|
+
return null;
|
|
61
|
+
return {
|
|
62
|
+
transport: 'stdio',
|
|
63
|
+
command: process.execPath,
|
|
64
|
+
args: [shim, 'serve', '--mcp', '--path', workDir],
|
|
65
|
+
label: 'CodeGraph (built-in)',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build the initial index for `workDir` if it has no `.codegraph/` yet.
|
|
70
|
+
*
|
|
71
|
+
* Non-blocking: spawns `codegraph init <workDir> -i` detached and returns
|
|
72
|
+
* immediately. The serving MCP process watches the project, so it picks up
|
|
73
|
+
* the freshly built index; until it's ready, codegraph tools report
|
|
74
|
+
* "not initialized" and the agent falls back to grep/read (no regression).
|
|
75
|
+
* No-op when CodeGraph is disabled, unavailable, or already initialized.
|
|
76
|
+
*/
|
|
77
|
+
export function ensureCodegraphIndex(workDir) {
|
|
78
|
+
if (!isCodegraphEnabled())
|
|
79
|
+
return;
|
|
80
|
+
const indexDir = path.join(workDir, '.codegraph');
|
|
81
|
+
if (fs.existsSync(indexDir))
|
|
82
|
+
return; // already initialized — watcher keeps it fresh
|
|
83
|
+
const shim = resolveCodegraphShim();
|
|
84
|
+
if (!shim)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
const child = spawn(process.execPath, [shim, 'init', workDir, '-i'], {
|
|
88
|
+
cwd: workDir,
|
|
89
|
+
// Discard output: this is best-effort background indexing. Failures are
|
|
90
|
+
// non-fatal — the agent simply keeps using grep/read until (and if) the
|
|
91
|
+
// index appears. Surfacing a stack trace here would just be noise.
|
|
92
|
+
stdio: 'ignore',
|
|
93
|
+
detached: true,
|
|
94
|
+
});
|
|
95
|
+
child.on('error', (err) => {
|
|
96
|
+
logger.debug(`[franklin] codegraph index build failed: ${err.message}`);
|
|
97
|
+
});
|
|
98
|
+
// Don't keep the event loop alive waiting on the indexer.
|
|
99
|
+
child.unref();
|
|
100
|
+
logger.info(`[franklin] CodeGraph: building initial index for ${workDir}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
logger.debug(`[franklin] codegraph index spawn error: ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/mcp/config.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { execSync } from 'node:child_process';
|
|
10
10
|
import { BLOCKRUN_DIR } from '../config.js';
|
|
11
|
+
import { getCodegraphServerConfig } from './codegraph.js';
|
|
11
12
|
const GLOBAL_MCP_FILE = path.join(BLOCKRUN_DIR, 'mcp.json');
|
|
12
13
|
/**
|
|
13
14
|
* Load MCP server configurations from global + project files.
|
|
@@ -46,6 +47,13 @@ export function loadMcpConfig(workDir) {
|
|
|
46
47
|
servers[name] = config;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
// Built-in CodeGraph: shipped as a dependency (not on PATH), so it's
|
|
51
|
+
// resolved + pinned to this workDir rather than probed via `which`.
|
|
52
|
+
// User config below can still override or disable it.
|
|
53
|
+
const codegraph = getCodegraphServerConfig(workDir);
|
|
54
|
+
if (codegraph) {
|
|
55
|
+
servers.codegraph = codegraph;
|
|
56
|
+
}
|
|
49
57
|
// 1. Global config
|
|
50
58
|
try {
|
|
51
59
|
if (fs.existsSync(GLOBAL_MCP_FILE)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockrun/franklin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.24.0",
|
|
4
4
|
"description": "Franklin Agent — 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": {
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@blockrun/llm": "^2.0.0",
|
|
70
|
+
"@colbymchenry/codegraph": "^0.9.7",
|
|
70
71
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
71
72
|
"@solana/spl-token": "^0.4.14",
|
|
72
73
|
"@solana/web3.js": "^1.98.4",
|
|
@@ -1,828 +0,0 @@
|
|
|
1
|
-
/**
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
* Powered server-side by Predexon; surfaced to the agent as a single
|
|
8
|
-
* action-dispatched tool so the inventory stays small. Keep one cohesive
|
|
9
|
-
* tool — the way TradingMarket bundles 6 actions — instead of forty
|
|
10
|
-
* one-shot capabilities, otherwise weak models start hallucinating tool
|
|
11
|
-
* names.
|
|
12
|
-
*
|
|
13
|
-
* searchAll $0.005 search markets across Polymarket+Kalshi+
|
|
14
|
-
* Limitless+Opinion+Predict.Fun in one call
|
|
15
|
-
* searchPolymarket $0.001 query Polymarket markets (event filter, sort)
|
|
16
|
-
* searchKalshi $0.001 query Kalshi markets
|
|
17
|
-
* crossPlatform $0.005 matching market pairs across Polymarket+Kalshi
|
|
18
|
-
* (the arbitrage / consensus signal)
|
|
19
|
-
* leaderboard $0.001 global Polymarket leaderboard — top wallets by P&L
|
|
20
|
-
* walletProfile $0.005 full Polymarket wallet profile (single wallet)
|
|
21
|
-
* or batch profiles (comma-separated wallets)
|
|
22
|
-
* walletPnl $0.005 P&L summary + realized P&L time series for one
|
|
23
|
-
* Polymarket wallet
|
|
24
|
-
* walletPositions $0.005 open + historical positions for one Polymarket
|
|
25
|
-
* wallet
|
|
26
|
-
* smartActivity $0.005 discover markets where high-performing wallets
|
|
27
|
-
* are active right now
|
|
28
|
-
* smartMoney $0.005 smart-money positioning on one Polymarket
|
|
29
|
-
* condition_id (per-market drill-down)
|
|
30
|
-
*
|
|
31
|
-
* Output is filtered + truncated on the way back so a single call never
|
|
32
|
-
* dumps 100 markets into the agent's context. Default 20 rows; agents that
|
|
33
|
-
* need more should narrow the search.
|
|
34
|
-
*/
|
|
35
|
-
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
36
|
-
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
37
|
-
import { logger } from '../logger.js';
|
|
38
|
-
import { recordFetch } from '../trading/providers/telemetry.js';
|
|
39
|
-
const TIMEOUT_MS = 30_000;
|
|
40
|
-
const DEFAULT_LIMIT = 20;
|
|
41
|
-
const MAX_LIMIT = 50;
|
|
42
|
-
// Per-action price table — mirrors the Predexon openapi.json. Used to feed
|
|
43
|
-
// the Markets-tab telemetry ring buffer so prediction-market spend appears
|
|
44
|
-
// in "Calls today / Spend today / Recent paid calls" alongside trading calls.
|
|
45
|
-
// If a path isn't here we don't record cost — we still record the fetch
|
|
46
|
-
// (success/latency) so panel health stays accurate.
|
|
47
|
-
const PATH_PRICES = [
|
|
48
|
-
{ pattern: /\/v1\/pm\/markets\/search$/, usd: 0.005 },
|
|
49
|
-
{ pattern: /\/v1\/pm\/matching-markets/, usd: 0.005 },
|
|
50
|
-
{ pattern: /\/v1\/pm\/polymarket\/wallets\//, usd: 0.005 },
|
|
51
|
-
{ pattern: /\/v1\/pm\/polymarket\/wallet\//, usd: 0.005 },
|
|
52
|
-
{ pattern: /\/v1\/pm\/polymarket\/market\/[^/]+\/smart-money$/, usd: 0.005 },
|
|
53
|
-
{ pattern: /\/v1\/pm\/polymarket\/markets\/smart-activity$/, usd: 0.005 },
|
|
54
|
-
{ pattern: /\/v1\/pm\/.+/, usd: 0.001 },
|
|
55
|
-
];
|
|
56
|
-
function priceForPath(path) {
|
|
57
|
-
for (const { pattern, usd } of PATH_PRICES) {
|
|
58
|
-
if (pattern.test(path))
|
|
59
|
-
return usd;
|
|
60
|
-
}
|
|
61
|
-
return 0;
|
|
62
|
-
}
|
|
63
|
-
// ─── Shared GET-with-x402 flow ────────────────────────────────────────────
|
|
64
|
-
async function getWithPayment(path, query, ctx) {
|
|
65
|
-
const chain = loadChain();
|
|
66
|
-
const apiUrl = API_URLS[chain];
|
|
67
|
-
const qs = new URLSearchParams();
|
|
68
|
-
for (const [k, v] of Object.entries(query)) {
|
|
69
|
-
if (v == null || v === '')
|
|
70
|
-
continue;
|
|
71
|
-
qs.set(k, String(v));
|
|
72
|
-
}
|
|
73
|
-
const queryStr = qs.toString();
|
|
74
|
-
const endpoint = `${apiUrl}${path}${queryStr ? `?${queryStr}` : ''}`;
|
|
75
|
-
const headers = {
|
|
76
|
-
Accept: 'application/json',
|
|
77
|
-
'User-Agent': `franklin/${VERSION}`,
|
|
78
|
-
};
|
|
79
|
-
const controller = new AbortController();
|
|
80
|
-
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
81
|
-
const onAbort = () => controller.abort();
|
|
82
|
-
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
83
|
-
const startedAt = Date.now();
|
|
84
|
-
let costRecorded = 0;
|
|
85
|
-
try {
|
|
86
|
-
let response = await fetch(endpoint, { method: 'GET', signal: controller.signal, headers });
|
|
87
|
-
if (response.status === 402) {
|
|
88
|
-
const paymentHeaders = await signPayment(response, chain, endpoint);
|
|
89
|
-
if (!paymentHeaders) {
|
|
90
|
-
throw new Error('Payment signing failed — check wallet balance');
|
|
91
|
-
}
|
|
92
|
-
response = await fetch(endpoint, {
|
|
93
|
-
method: 'GET',
|
|
94
|
-
signal: controller.signal,
|
|
95
|
-
headers: { ...headers, ...paymentHeaders },
|
|
96
|
-
});
|
|
97
|
-
// Only record cost on the post-402 settlement; the initial 402
|
|
98
|
-
// response is free and counting it would double-charge the panel.
|
|
99
|
-
costRecorded = priceForPath(path);
|
|
100
|
-
}
|
|
101
|
-
if (!response.ok) {
|
|
102
|
-
const errText = await response.text().catch(() => '');
|
|
103
|
-
// Surface failed paid calls in the Markets-tab health summary.
|
|
104
|
-
recordFetch({ provider: 'blockrun', endpoint: path, ok: false, latencyMs: Date.now() - startedAt });
|
|
105
|
-
throw new Error(`PredictionMarket ${path} failed (${response.status}): ${errText.slice(0, 600)}`);
|
|
106
|
-
}
|
|
107
|
-
recordFetch({
|
|
108
|
-
provider: 'blockrun',
|
|
109
|
-
endpoint: path,
|
|
110
|
-
ok: true,
|
|
111
|
-
latencyMs: Date.now() - startedAt,
|
|
112
|
-
costUsd: costRecorded > 0 ? costRecorded : undefined,
|
|
113
|
-
});
|
|
114
|
-
return (await response.json());
|
|
115
|
-
}
|
|
116
|
-
finally {
|
|
117
|
-
clearTimeout(timeout);
|
|
118
|
-
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
async function signPayment(response, chain, endpoint) {
|
|
122
|
-
try {
|
|
123
|
-
const paymentHeader = await extractPaymentReq(response);
|
|
124
|
-
if (!paymentHeader)
|
|
125
|
-
return null;
|
|
126
|
-
if (chain === 'solana') {
|
|
127
|
-
const wallet = await getOrCreateSolanaWallet();
|
|
128
|
-
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
129
|
-
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
130
|
-
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
|
|
131
|
-
const feePayer = details.extra?.feePayer || details.recipient;
|
|
132
|
-
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
133
|
-
resourceUrl: details.resource?.url || endpoint,
|
|
134
|
-
resourceDescription: details.resource?.description || 'Franklin PredictionMarket call',
|
|
135
|
-
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
|
|
136
|
-
extra: details.extra,
|
|
137
|
-
});
|
|
138
|
-
return { 'PAYMENT-SIGNATURE': payload };
|
|
139
|
-
}
|
|
140
|
-
const wallet = await getOrCreateWallet();
|
|
141
|
-
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
142
|
-
const details = extractPaymentDetails(paymentRequired);
|
|
143
|
-
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
144
|
-
resourceUrl: details.resource?.url || endpoint,
|
|
145
|
-
resourceDescription: details.resource?.description || 'Franklin PredictionMarket call',
|
|
146
|
-
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
|
|
147
|
-
extra: details.extra,
|
|
148
|
-
});
|
|
149
|
-
return { 'PAYMENT-SIGNATURE': payload };
|
|
150
|
-
}
|
|
151
|
-
catch (err) {
|
|
152
|
-
logger.warn(`[franklin] PredictionMarket payment error: ${err.message}`);
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
async function extractPaymentReq(response) {
|
|
157
|
-
let header = response.headers.get('payment-required');
|
|
158
|
-
if (!header) {
|
|
159
|
-
try {
|
|
160
|
-
const body = (await response.json());
|
|
161
|
-
if (body.x402 || body.accepts)
|
|
162
|
-
header = btoa(JSON.stringify(body));
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
/* ignore */
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return header;
|
|
169
|
-
}
|
|
170
|
-
// ─── Formatting helpers ────────────────────────────────────────────────────
|
|
171
|
-
function asNumber(value) {
|
|
172
|
-
if (typeof value === 'number' && Number.isFinite(value))
|
|
173
|
-
return value;
|
|
174
|
-
if (typeof value === 'string' && value.trim() !== '') {
|
|
175
|
-
const n = Number(value);
|
|
176
|
-
if (Number.isFinite(n))
|
|
177
|
-
return n;
|
|
178
|
-
}
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
function formatUsd(value) {
|
|
182
|
-
const n = asNumber(value);
|
|
183
|
-
if (n == null)
|
|
184
|
-
return 'n/a';
|
|
185
|
-
if (n >= 1e9)
|
|
186
|
-
return `$${(n / 1e9).toFixed(2)}B`;
|
|
187
|
-
if (n >= 1e6)
|
|
188
|
-
return `$${(n / 1e6).toFixed(2)}M`;
|
|
189
|
-
if (n >= 1e3)
|
|
190
|
-
return `$${(n / 1e3).toFixed(1)}K`;
|
|
191
|
-
return `$${n.toFixed(2)}`;
|
|
192
|
-
}
|
|
193
|
-
function formatQuantity(value) {
|
|
194
|
-
const n = asNumber(value);
|
|
195
|
-
if (n == null)
|
|
196
|
-
return String(value ?? 'n/a');
|
|
197
|
-
return Number.isInteger(n) ? n.toLocaleString() : n.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
198
|
-
}
|
|
199
|
-
function formatPct(value, digits = 1) {
|
|
200
|
-
const n = asNumber(value);
|
|
201
|
-
if (n == null)
|
|
202
|
-
return 'n/a';
|
|
203
|
-
const pct = Math.abs(n) > 1 ? n : n * 100;
|
|
204
|
-
return `${pct.toFixed(digits)}%`;
|
|
205
|
-
}
|
|
206
|
-
// API responses sometimes come wrapped as `{data: [...], pagination: ...}`,
|
|
207
|
-
// other times as a bare array. Normalise to an array.
|
|
208
|
-
function unwrapList(raw) {
|
|
209
|
-
if (Array.isArray(raw))
|
|
210
|
-
return raw;
|
|
211
|
-
if (raw && typeof raw === 'object') {
|
|
212
|
-
const obj = raw;
|
|
213
|
-
if (Array.isArray(obj.data))
|
|
214
|
-
return obj.data;
|
|
215
|
-
if (Array.isArray(obj.markets))
|
|
216
|
-
return obj.markets;
|
|
217
|
-
if (Array.isArray(obj.pairs))
|
|
218
|
-
return obj.pairs;
|
|
219
|
-
if (Array.isArray(obj.results))
|
|
220
|
-
return obj.results;
|
|
221
|
-
if (Array.isArray(obj.positions))
|
|
222
|
-
return obj.positions;
|
|
223
|
-
}
|
|
224
|
-
return [];
|
|
225
|
-
}
|
|
226
|
-
function parseWalletsInput(value) {
|
|
227
|
-
return value
|
|
228
|
-
.split(',')
|
|
229
|
-
.map(w => w.trim())
|
|
230
|
-
.filter(Boolean);
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Pick the first usable string from a list of candidate values.
|
|
234
|
-
*
|
|
235
|
-
* Predexon responses sometimes wrap titles/labels inside nested objects
|
|
236
|
-
* (e.g. `position.market = { slug, question, title }` instead of a flat
|
|
237
|
-
* `position.title`). Pre-3.15.75 the formatter `as string` cast these
|
|
238
|
-
* objects and ended up rendering `[object Object]` for every position
|
|
239
|
-
* row — verified 2026-05-06 in a real session.
|
|
240
|
-
*
|
|
241
|
-
* Strategy:
|
|
242
|
-
* - string → return as-is (after trim)
|
|
243
|
-
* - object → walk a small set of common name-bearing keys
|
|
244
|
-
* (title, question, slug, name, label, market_slug) and return the
|
|
245
|
-
* first one that yields a string
|
|
246
|
-
* - anything else (number / array / null) → skip
|
|
247
|
-
* - all candidates exhausted → undefined
|
|
248
|
-
*/
|
|
249
|
-
function pickString(...candidates) {
|
|
250
|
-
const NAME_KEYS = ['title', 'question', 'slug', 'name', 'label', 'market_slug', 'event_title'];
|
|
251
|
-
for (const c of candidates) {
|
|
252
|
-
if (typeof c === 'string') {
|
|
253
|
-
const trimmed = c.trim();
|
|
254
|
-
if (trimmed)
|
|
255
|
-
return trimmed;
|
|
256
|
-
}
|
|
257
|
-
else if (c && typeof c === 'object' && !Array.isArray(c)) {
|
|
258
|
-
const obj = c;
|
|
259
|
-
for (const k of NAME_KEYS) {
|
|
260
|
-
const v = obj[k];
|
|
261
|
-
if (typeof v === 'string' && v.trim())
|
|
262
|
-
return v.trim();
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return undefined;
|
|
267
|
-
}
|
|
268
|
-
async function execute(input, ctx) {
|
|
269
|
-
const { action, search, status, sort, limit, conditionId, wallets, granularity } = input;
|
|
270
|
-
const cappedLimit = Math.min(Math.max(1, limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
271
|
-
if (!action) {
|
|
272
|
-
return {
|
|
273
|
-
output: 'Error: action is required (searchAll | searchPolymarket | searchKalshi | crossPlatform | leaderboard | walletProfile | walletPnl | walletPositions | smartActivity | smartMoney)',
|
|
274
|
-
isError: true,
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
switch (action) {
|
|
279
|
-
case 'searchAll': {
|
|
280
|
-
// One $0.005 call across 5 platforms — Polymarket, Kalshi, Limitless,
|
|
281
|
-
// Opinion, Predict.Fun. The right entry point for "is there a market
|
|
282
|
-
// on X anywhere?" — beats firing per-platform searches in parallel.
|
|
283
|
-
// Predexon expects `q` for the search term — verified 2026-05-06 from
|
|
284
|
-
// a live 422: {"detail":[{"type":"missing","loc":["query","q"]}]}.
|
|
285
|
-
// Public input field stays `search` for ergonomic consistency with
|
|
286
|
-
// searchPolymarket / searchKalshi; rename on the wire.
|
|
287
|
-
const raw = await getWithPayment('/v1/pm/markets/search', {
|
|
288
|
-
q: search,
|
|
289
|
-
status,
|
|
290
|
-
sort,
|
|
291
|
-
limit: cappedLimit,
|
|
292
|
-
}, ctx);
|
|
293
|
-
// Predexon returns either a flat list or per-platform buckets.
|
|
294
|
-
// Try the bucket shape first; fall back to a flat list.
|
|
295
|
-
const lines = [
|
|
296
|
-
`## Cross-platform market search` + (search ? ` · "${search}"` : ''),
|
|
297
|
-
'_Searched Polymarket, Kalshi, Limitless, Opinion, Predict.Fun in one call._',
|
|
298
|
-
'',
|
|
299
|
-
];
|
|
300
|
-
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
301
|
-
const obj = raw;
|
|
302
|
-
const platforms = ['polymarket', 'kalshi', 'limitless', 'opinion', 'predictfun', 'predict_fun'];
|
|
303
|
-
let totalShown = 0;
|
|
304
|
-
for (const p of platforms) {
|
|
305
|
-
const list = unwrapList(obj[p]);
|
|
306
|
-
if (list.length === 0)
|
|
307
|
-
continue;
|
|
308
|
-
const remaining = cappedLimit - totalShown;
|
|
309
|
-
if (remaining <= 0)
|
|
310
|
-
break;
|
|
311
|
-
const shown = list.slice(0, Math.min(5, remaining));
|
|
312
|
-
lines.push(`### ${p}`);
|
|
313
|
-
shown.forEach((m, i) => {
|
|
314
|
-
const title = pickString(m.title, m.question, m.market, m.event, m.market_slug, m.slug, m.ticker) ?? 'untitled';
|
|
315
|
-
const id = pickString(m.condition_id, m.ticker, m.id);
|
|
316
|
-
const idTag = id ? ` · \`${String(id).slice(0, 18)}…\`` : '';
|
|
317
|
-
const vol = m.volume != null ? ` · vol ${formatUsd(m.volume)}` : '';
|
|
318
|
-
lines.push(`${i + 1}. ${title}${idTag}${vol}`);
|
|
319
|
-
totalShown++;
|
|
320
|
-
});
|
|
321
|
-
lines.push('');
|
|
322
|
-
}
|
|
323
|
-
if (totalShown === 0) {
|
|
324
|
-
// Bucket shape but empty — fall back to flat-list interpretation.
|
|
325
|
-
const flat = unwrapList(raw);
|
|
326
|
-
if (flat.length === 0) {
|
|
327
|
-
return { output: 'No markets matched across any platform.' };
|
|
328
|
-
}
|
|
329
|
-
flat.slice(0, cappedLimit).forEach((m, i) => {
|
|
330
|
-
const title = pickString(m.title, m.question, m.market, m.event, m.market_slug, m.slug, m.ticker) ?? 'untitled';
|
|
331
|
-
const platform = pickString(m.platform, m.source) ?? 'unknown';
|
|
332
|
-
lines.push(`${i + 1}. **[${platform}]** ${title}`);
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
else {
|
|
337
|
-
const flat = unwrapList(raw);
|
|
338
|
-
if (flat.length === 0) {
|
|
339
|
-
return { output: 'No markets matched across any platform.' };
|
|
340
|
-
}
|
|
341
|
-
flat.slice(0, cappedLimit).forEach((m, i) => {
|
|
342
|
-
const title = (m.title || m.question || m.market_slug || m.ticker || 'untitled');
|
|
343
|
-
const platform = (m.platform || m.source || 'unknown');
|
|
344
|
-
lines.push(`${i + 1}. **[${platform}]** ${title}`);
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
lines.push(`_$0.005 paid via x402._`);
|
|
348
|
-
return { output: lines.join('\n') };
|
|
349
|
-
}
|
|
350
|
-
case 'leaderboard': {
|
|
351
|
-
// Global top-wallet ranking. Cheap ($0.001) — the right answer to
|
|
352
|
-
// "who's making money on Polymarket" / "who should I follow".
|
|
353
|
-
const raw = await getWithPayment('/v1/pm/polymarket/leaderboard', {
|
|
354
|
-
limit: cappedLimit,
|
|
355
|
-
sort,
|
|
356
|
-
}, ctx);
|
|
357
|
-
const rows = unwrapList(raw);
|
|
358
|
-
if (rows.length === 0) {
|
|
359
|
-
return { output: 'No leaderboard data returned.' };
|
|
360
|
-
}
|
|
361
|
-
const lines = [
|
|
362
|
-
`## Polymarket leaderboard — top ${rows.length} wallet${rows.length === 1 ? '' : 's'}`,
|
|
363
|
-
'',
|
|
364
|
-
];
|
|
365
|
-
rows.forEach((r, i) => {
|
|
366
|
-
const wallet = pickString(r.wallet, r.address, r.proxy_wallet, r.proxyWallet) ?? 'unknown';
|
|
367
|
-
const w = wallet.length > 12
|
|
368
|
-
? `${wallet.slice(0, 8)}…${wallet.slice(-4)}`
|
|
369
|
-
: wallet;
|
|
370
|
-
const pnl = r.pnl ?? r.realized_pnl ?? r.total_pnl;
|
|
371
|
-
const volume = r.volume ?? r.total_volume;
|
|
372
|
-
const winRate = r.win_rate ?? r.winRate;
|
|
373
|
-
const name = pickString(r.name, r.handle, r.username);
|
|
374
|
-
const handle = name ? ` (${name})` : '';
|
|
375
|
-
const parts = [];
|
|
376
|
-
if (pnl != null)
|
|
377
|
-
parts.push(`P&L ${formatUsd(pnl)}`);
|
|
378
|
-
if (volume != null)
|
|
379
|
-
parts.push(`vol ${formatUsd(volume)}`);
|
|
380
|
-
if (winRate != null)
|
|
381
|
-
parts.push(`win ${formatPct(winRate, 0)}`);
|
|
382
|
-
lines.push(`${i + 1}. \`${w}\`${handle}` + (parts.length > 0 ? ` — ${parts.join(' · ')}` : ''));
|
|
383
|
-
});
|
|
384
|
-
lines.push('', `_$0.001 paid via x402._`);
|
|
385
|
-
return { output: lines.join('\n') };
|
|
386
|
-
}
|
|
387
|
-
case 'walletProfile': {
|
|
388
|
-
if (!wallets || !wallets.trim()) {
|
|
389
|
-
return {
|
|
390
|
-
output: 'Error: `wallets` is required for walletProfile (single address or comma-separated list of Polymarket wallet addresses)',
|
|
391
|
-
isError: true,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
// Smart dispatch: a single wallet → /wallet/{addr} (full profile,
|
|
395
|
-
// labels, scores, stats); a comma-list → /wallets/profiles (batch).
|
|
396
|
-
// The 3.15.70 ship hit the BATCH endpoint for everything and got 422
|
|
397
|
-
// for the single-wallet case; the gateway team confirmed 2026-05-06
|
|
398
|
-
// the right surface for "analyze this trader" is the path-parameter
|
|
399
|
-
// single-wallet endpoint, not the batch query-param one.
|
|
400
|
-
const parsedWallets = parseWalletsInput(wallets);
|
|
401
|
-
if (parsedWallets.length === 0) {
|
|
402
|
-
return {
|
|
403
|
-
output: 'Error: `wallets` must include at least one Polymarket wallet address',
|
|
404
|
-
isError: true,
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
const list = parsedWallets.join(',');
|
|
408
|
-
const isBatch = parsedWallets.length > 1;
|
|
409
|
-
const raw = isBatch
|
|
410
|
-
? await getWithPayment('/v1/pm/polymarket/wallets/profiles', {
|
|
411
|
-
addresses: list,
|
|
412
|
-
}, ctx)
|
|
413
|
-
: await getWithPayment(`/v1/pm/polymarket/wallet/${encodeURIComponent(list)}`, {}, ctx);
|
|
414
|
-
// Single-wallet path returns a single profile object; batch returns
|
|
415
|
-
// an array (or {data:[]}). unwrapList handles the batch shape but
|
|
416
|
-
// returns [] for a bare object — wrap explicitly so the formatter
|
|
417
|
-
// below sees the single profile.
|
|
418
|
-
if (process.env.FRANKLIN_PM_DEBUG === '1') process.stderr.write('[pm-debug] walletProfile raw: ' + JSON.stringify(raw).slice(0,1500) + '\n');
|
|
419
|
-
const profiles = isBatch
|
|
420
|
-
? unwrapList(raw)
|
|
421
|
-
: (raw && typeof raw === 'object' ? [raw] : []);
|
|
422
|
-
if (profiles.length === 0) {
|
|
423
|
-
return { output: `No profile data returned for: ${wallets}` };
|
|
424
|
-
}
|
|
425
|
-
const lines = [
|
|
426
|
-
`## Polymarket wallet profile${profiles.length === 1 ? '' : 's'} — ${profiles.length}`,
|
|
427
|
-
'',
|
|
428
|
-
];
|
|
429
|
-
profiles.forEach((p, i) => {
|
|
430
|
-
const wallet = pickString(p.wallet, p.address, p.proxy_wallet, p.proxyWallet) ?? 'unknown';
|
|
431
|
-
const w = wallet.length > 12
|
|
432
|
-
? `${wallet.slice(0, 8)}…${wallet.slice(-4)}`
|
|
433
|
-
: wallet;
|
|
434
|
-
const name = pickString(p.name, p.handle, p.username);
|
|
435
|
-
const pnl = p.pnl ?? p.realized_pnl ?? p.total_pnl;
|
|
436
|
-
const unrealized = p.unrealized_pnl;
|
|
437
|
-
const volume = p.volume ?? p.total_volume;
|
|
438
|
-
const positions = p.positions_count ?? p.open_positions;
|
|
439
|
-
const winRate = p.win_rate ?? p.winRate;
|
|
440
|
-
lines.push(`${i + 1}. \`${w}\`` + (name ? ` (${name})` : ''));
|
|
441
|
-
const stats = [];
|
|
442
|
-
if (pnl != null)
|
|
443
|
-
stats.push(`P&L ${formatUsd(pnl)}`);
|
|
444
|
-
if (unrealized != null)
|
|
445
|
-
stats.push(`unrealized ${formatUsd(unrealized)}`);
|
|
446
|
-
if (volume != null)
|
|
447
|
-
stats.push(`vol ${formatUsd(volume)}`);
|
|
448
|
-
if (positions != null)
|
|
449
|
-
stats.push(`${positions} open`);
|
|
450
|
-
if (winRate != null)
|
|
451
|
-
stats.push(`win ${formatPct(winRate, 0)}`);
|
|
452
|
-
if (stats.length > 0)
|
|
453
|
-
lines.push(` ${stats.join(' · ')}`);
|
|
454
|
-
});
|
|
455
|
-
lines.push('', `_$0.005 paid via x402._`);
|
|
456
|
-
return { output: lines.join('\n') };
|
|
457
|
-
}
|
|
458
|
-
case 'walletPnl': {
|
|
459
|
-
// Single-wallet P&L summary + time series.
|
|
460
|
-
// Predexon path: /v1/pm/polymarket/wallet/pnl/{wallet} — Tier 2 ($0.005).
|
|
461
|
-
if (!wallets || !wallets.trim()) {
|
|
462
|
-
return {
|
|
463
|
-
output: 'Error: `wallets` is required for walletPnl (single Polymarket wallet address)',
|
|
464
|
-
isError: true,
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
const parsedWallets = parseWalletsInput(wallets);
|
|
468
|
-
if (parsedWallets.length !== 1) {
|
|
469
|
-
return {
|
|
470
|
-
output: 'Error: walletPnl accepts exactly one wallet address. For multiple wallets, call walletPnl once per address in parallel.',
|
|
471
|
-
isError: true,
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
const wallet = parsedWallets[0];
|
|
475
|
-
// Predexon requires `granularity` from the enum {day, week, month,
|
|
476
|
-
// year, all} — verified 2026-05-06 in two live 422 turns. Default
|
|
477
|
-
// `day`; agent can override via input field for longer aggregations.
|
|
478
|
-
const raw = await getWithPayment(`/v1/pm/polymarket/wallet/pnl/${encodeURIComponent(wallet)}`, { granularity: granularity ?? 'day' }, ctx);
|
|
479
|
-
if (process.env.FRANKLIN_PM_DEBUG === '1') process.stderr.write('[pm-debug] walletPnl raw: ' + JSON.stringify(raw).slice(0,1500) + '\n');
|
|
480
|
-
if (!raw || typeof raw !== 'object') {
|
|
481
|
-
return { output: `No P&L data returned for ${wallet}` };
|
|
482
|
-
}
|
|
483
|
-
const data = raw;
|
|
484
|
-
const realized = data.realized_pnl ?? data.realizedPnl ?? data.total_pnl ?? data.pnl;
|
|
485
|
-
const unrealized = data.unrealized_pnl ?? data.unrealizedPnl;
|
|
486
|
-
const total = data.total_value ?? data.totalValue ?? data.equity;
|
|
487
|
-
const volume = data.volume ?? data.total_volume;
|
|
488
|
-
const winRate = data.win_rate ?? data.winRate;
|
|
489
|
-
const w = wallet.length > 12 ? `${wallet.slice(0, 8)}…${wallet.slice(-4)}` : wallet;
|
|
490
|
-
const lines = [`## Polymarket wallet P&L — \`${w}\``, ''];
|
|
491
|
-
const summary = [];
|
|
492
|
-
if (realized != null)
|
|
493
|
-
summary.push(`realized ${formatUsd(realized)}`);
|
|
494
|
-
if (unrealized != null)
|
|
495
|
-
summary.push(`unrealized ${formatUsd(unrealized)}`);
|
|
496
|
-
if (total != null)
|
|
497
|
-
summary.push(`equity ${formatUsd(total)}`);
|
|
498
|
-
if (volume != null)
|
|
499
|
-
summary.push(`vol ${formatUsd(volume)}`);
|
|
500
|
-
if (winRate != null)
|
|
501
|
-
summary.push(`win ${formatPct(winRate, 0)}`);
|
|
502
|
-
if (summary.length > 0)
|
|
503
|
-
lines.push(summary.join(' · '));
|
|
504
|
-
// Optional time series — show recent points compactly if present.
|
|
505
|
-
const series = (data.series ?? data.history ?? data.daily);
|
|
506
|
-
if (Array.isArray(series) && series.length > 0) {
|
|
507
|
-
lines.push('', `**Recent points** (latest ${Math.min(7, series.length)}):`);
|
|
508
|
-
series.slice(-7).forEach(pt => {
|
|
509
|
-
const t = (pt.date ?? pt.ts ?? pt.timestamp);
|
|
510
|
-
const v = (pt.pnl ?? pt.value ?? pt.cumulative_pnl);
|
|
511
|
-
if (t != null && v != null) {
|
|
512
|
-
const tStr = typeof t === 'number' ? new Date(t).toISOString().slice(0, 10) : String(t).slice(0, 10);
|
|
513
|
-
lines.push(`- ${tStr} · ${formatUsd(v)}`);
|
|
514
|
-
}
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
lines.push('', `_$0.005 paid via x402._`);
|
|
518
|
-
return { output: lines.join('\n') };
|
|
519
|
-
}
|
|
520
|
-
case 'walletPositions': {
|
|
521
|
-
// Single-wallet positions (open + historical).
|
|
522
|
-
// Predexon path: /v1/pm/polymarket/wallet/positions/{wallet} — Tier 2 ($0.005).
|
|
523
|
-
if (!wallets || !wallets.trim()) {
|
|
524
|
-
return {
|
|
525
|
-
output: 'Error: `wallets` is required for walletPositions (single Polymarket wallet address)',
|
|
526
|
-
isError: true,
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
const parsedWallets = parseWalletsInput(wallets);
|
|
530
|
-
if (parsedWallets.length !== 1) {
|
|
531
|
-
return {
|
|
532
|
-
output: 'Error: walletPositions accepts exactly one wallet address. For multiple wallets, call walletPositions once per address in parallel.',
|
|
533
|
-
isError: true,
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
const wallet = parsedWallets[0];
|
|
537
|
-
const raw = await getWithPayment(`/v1/pm/polymarket/wallet/positions/${encodeURIComponent(wallet)}`, { limit: cappedLimit }, ctx);
|
|
538
|
-
const positions = unwrapList(raw);
|
|
539
|
-
if (positions.length === 0) {
|
|
540
|
-
return { output: `No positions returned for ${wallet}` };
|
|
541
|
-
}
|
|
542
|
-
const w = wallet.length > 12 ? `${wallet.slice(0, 8)}…${wallet.slice(-4)}` : wallet;
|
|
543
|
-
const lines = [
|
|
544
|
-
`## Polymarket positions — \`${w}\` — ${positions.length} position${positions.length === 1 ? '' : 's'}`,
|
|
545
|
-
'',
|
|
546
|
-
];
|
|
547
|
-
// Predexon returns each position as a nested record:
|
|
548
|
-
// { market: {title, side_label, ...},
|
|
549
|
-
// position: {shares, avg_entry_price, total_cost_usd, ...},
|
|
550
|
-
// current: {price, value_usd},
|
|
551
|
-
// pnl: {unrealized_usd, unrealized_pct, realized_usd} }
|
|
552
|
-
// Verified 2026-05-06 via FRANKLIN_PM_DEBUG=1 dump. Walk the four
|
|
553
|
-
// sub-objects rather than assuming flat fields. Keep flat-field
|
|
554
|
-
// fallbacks too in case the response shape changes or the user's
|
|
555
|
-
// gateway version returns a flatter format.
|
|
556
|
-
positions.slice(0, cappedLimit).forEach((p, i) => {
|
|
557
|
-
const market = (p.market && typeof p.market === 'object' ? p.market : {});
|
|
558
|
-
const position = (p.position && typeof p.position === 'object' ? p.position : {});
|
|
559
|
-
const current = (p.current && typeof p.current === 'object' ? p.current : {});
|
|
560
|
-
const pnlObj = (p.pnl && typeof p.pnl === 'object' ? p.pnl : {});
|
|
561
|
-
const title = pickString(market.title, market.question, p.title, p.question, market.market_slug, p.market_slug) ?? 'untitled';
|
|
562
|
-
const outcome = pickString(market.side_label, market.side, p.outcome, p.side);
|
|
563
|
-
const shares = position.shares ?? position.total_shares_bought ?? p.size ?? p.shares;
|
|
564
|
-
const avgPrice = position.avg_entry_price ?? p.avg_price ?? p.avgPrice;
|
|
565
|
-
const currentValue = current.value_usd ?? p.current_value ?? p.currentValue ?? p.value;
|
|
566
|
-
const pnl = pnlObj.unrealized_usd ?? pnlObj.realized_usd ?? p.cashPnl ?? p.pnl;
|
|
567
|
-
const pnlPct = pnlObj.unrealized_pct ?? pnlObj.realized_pct ?? p.percentPnl ?? p.percent_pnl;
|
|
568
|
-
const parts = [];
|
|
569
|
-
if (outcome)
|
|
570
|
-
parts.push(outcome);
|
|
571
|
-
if (shares != null)
|
|
572
|
-
parts.push(`${formatQuantity(shares)} shares`);
|
|
573
|
-
if (avgPrice != null)
|
|
574
|
-
parts.push(`avg ${formatPct(avgPrice)}`);
|
|
575
|
-
if (currentValue != null)
|
|
576
|
-
parts.push(`now ${formatUsd(currentValue)}`);
|
|
577
|
-
if (pnl != null) {
|
|
578
|
-
const pctStr = pnlPct != null ? ` (${formatPct(pnlPct, 1)})` : '';
|
|
579
|
-
parts.push(`P&L ${formatUsd(pnl)}${pctStr}`);
|
|
580
|
-
}
|
|
581
|
-
lines.push(`${i + 1}. **${title}** — ${parts.join(' · ')}`);
|
|
582
|
-
});
|
|
583
|
-
lines.push('', `_$0.005 paid via x402._`);
|
|
584
|
-
return { output: lines.join('\n') };
|
|
585
|
-
}
|
|
586
|
-
case 'smartActivity': {
|
|
587
|
-
// "Discover markets where high-performing wallets are active right now."
|
|
588
|
-
// Complements `smartMoney`: this discovers interesting markets across
|
|
589
|
-
// the venue; smartMoney drills into one condition_id.
|
|
590
|
-
const raw = await getWithPayment('/v1/pm/polymarket/markets/smart-activity', {
|
|
591
|
-
limit: cappedLimit,
|
|
592
|
-
search,
|
|
593
|
-
}, ctx);
|
|
594
|
-
const rows = unwrapList(raw);
|
|
595
|
-
if (rows.length === 0) {
|
|
596
|
-
return { output: 'No smart-money activity recorded right now.' };
|
|
597
|
-
}
|
|
598
|
-
const lines = [
|
|
599
|
-
`## Smart-money activity — ${rows.length} market${rows.length === 1 ? '' : 's'}`,
|
|
600
|
-
'_Markets where high-P&L Polymarket wallets are positioning right now._',
|
|
601
|
-
'',
|
|
602
|
-
];
|
|
603
|
-
rows.forEach((r, i) => {
|
|
604
|
-
const title = pickString(r.question, r.title, r.market, r.event, r.market_slug, r.slug) ?? 'untitled';
|
|
605
|
-
const cid = pickString(r.condition_id, r.id);
|
|
606
|
-
const cidTag = cid ? ` · \`${String(cid).slice(0, 14)}…\`` : '';
|
|
607
|
-
const smartCount = r.smart_wallets_count ?? r.wallet_count;
|
|
608
|
-
const netFlow = r.net_size ?? r.net_yes_size;
|
|
609
|
-
const stats = [];
|
|
610
|
-
if (smartCount != null)
|
|
611
|
-
stats.push(`${smartCount} smart wallet${smartCount === 1 ? '' : 's'}`);
|
|
612
|
-
if (netFlow != null)
|
|
613
|
-
stats.push(`net ${formatUsd(netFlow)}`);
|
|
614
|
-
lines.push(`${i + 1}. **${title}**${cidTag}` + (stats.length > 0 ? `\n ${stats.join(' · ')}` : ''));
|
|
615
|
-
});
|
|
616
|
-
lines.push('', `_$0.005 paid via x402._`);
|
|
617
|
-
return { output: lines.join('\n') };
|
|
618
|
-
}
|
|
619
|
-
case 'smartMoney': {
|
|
620
|
-
if (!conditionId) {
|
|
621
|
-
return {
|
|
622
|
-
output: 'Error: conditionId is required for smartMoney (Polymarket condition_id from a prior searchPolymarket or smartActivity call)',
|
|
623
|
-
isError: true,
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
// Per-market drill-down. Official live registry:
|
|
627
|
-
// /api/v1/pm/polymarket/market/:condition_id/smart-money
|
|
628
|
-
const path = `/v1/pm/polymarket/market/${encodeURIComponent(conditionId)}/smart-money`;
|
|
629
|
-
const data = await getWithPayment(path, {}, ctx);
|
|
630
|
-
const buyers = (data.buyers ?? []).slice(0, 5);
|
|
631
|
-
const sellers = (data.sellers ?? []).slice(0, 5);
|
|
632
|
-
const lines = [
|
|
633
|
-
`## Smart money — \`${conditionId.slice(0, 14)}…\``,
|
|
634
|
-
];
|
|
635
|
-
if (data.net_yes_size != null || data.net_no_size != null) {
|
|
636
|
-
lines.push(`**Net flow:** YES ${formatUsd(data.net_yes_size)} / NO ${formatUsd(data.net_no_size)}`);
|
|
637
|
-
}
|
|
638
|
-
if (buyers.length > 0) {
|
|
639
|
-
lines.push('', '**Top buyers**');
|
|
640
|
-
buyers.forEach((b, i) => {
|
|
641
|
-
const w = b.wallet ? `${b.wallet.slice(0, 8)}…${b.wallet.slice(-4)}` : 'unknown';
|
|
642
|
-
lines.push(`${i + 1}. ${w} — ${formatUsd(b.size)} on ${b.outcome ?? 'unknown side'}`);
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
if (sellers.length > 0) {
|
|
646
|
-
lines.push('', '**Top sellers**');
|
|
647
|
-
sellers.forEach((s, i) => {
|
|
648
|
-
const w = s.wallet ? `${s.wallet.slice(0, 8)}…${s.wallet.slice(-4)}` : 'unknown';
|
|
649
|
-
lines.push(`${i + 1}. ${w} — ${formatUsd(s.size)} on ${s.outcome ?? 'unknown side'}`);
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
if (buyers.length === 0 && sellers.length === 0) {
|
|
653
|
-
lines.push('No smart-money flow recorded for this market yet.');
|
|
654
|
-
}
|
|
655
|
-
lines.push('', `_$0.005 paid via x402._`);
|
|
656
|
-
return { output: lines.join('\n') };
|
|
657
|
-
}
|
|
658
|
-
case 'searchPolymarket': {
|
|
659
|
-
const raw = await getWithPayment('/v1/pm/polymarket/markets', {
|
|
660
|
-
search,
|
|
661
|
-
status: status ?? 'active',
|
|
662
|
-
sort: sort ?? 'volume',
|
|
663
|
-
limit: cappedLimit,
|
|
664
|
-
}, ctx);
|
|
665
|
-
const markets = unwrapList(raw);
|
|
666
|
-
if (markets.length === 0) {
|
|
667
|
-
return { output: 'No Polymarket markets matched the filters.' };
|
|
668
|
-
}
|
|
669
|
-
const lines = [
|
|
670
|
-
`## Polymarket — ${markets.length} market${markets.length === 1 ? '' : 's'}` +
|
|
671
|
-
(search ? ` · search="${search}"` : '') +
|
|
672
|
-
(status ? ` · status=${status}` : '') +
|
|
673
|
-
` · sort=${sort ?? 'volume'}`,
|
|
674
|
-
'',
|
|
675
|
-
];
|
|
676
|
-
markets.forEach((m, i) => {
|
|
677
|
-
const yesPx = m.outcomes && m.outcome_prices && m.outcomes.length === m.outcome_prices.length
|
|
678
|
-
? m.outcomes.map((o, j) => `${o}=${formatPct(m.outcome_prices[j])}`).join(' / ')
|
|
679
|
-
: 'n/a';
|
|
680
|
-
const cid = m.condition_id ? ` · condition_id=\`${m.condition_id.slice(0, 14)}…\`` : '';
|
|
681
|
-
lines.push(`${i + 1}. **${m.question || m.market_slug || 'untitled'}**${cid}\n` +
|
|
682
|
-
` prices: ${yesPx} · vol: ${formatUsd(m.volume)} · liq: ${formatUsd(m.liquidity)}` +
|
|
683
|
-
(m.end_date ? ` · ends ${m.end_date.slice(0, 10)}` : ''));
|
|
684
|
-
});
|
|
685
|
-
lines.push('', `_$0.001 paid via x402._`);
|
|
686
|
-
return { output: lines.join('\n') };
|
|
687
|
-
}
|
|
688
|
-
case 'searchKalshi': {
|
|
689
|
-
const raw = await getWithPayment('/v1/pm/kalshi/markets', {
|
|
690
|
-
search,
|
|
691
|
-
status: status ?? 'open',
|
|
692
|
-
sort: sort ?? 'volume',
|
|
693
|
-
limit: cappedLimit,
|
|
694
|
-
}, ctx);
|
|
695
|
-
const markets = unwrapList(raw);
|
|
696
|
-
if (markets.length === 0) {
|
|
697
|
-
return { output: 'No Kalshi markets matched the filters.' };
|
|
698
|
-
}
|
|
699
|
-
const lines = [
|
|
700
|
-
`## Kalshi — ${markets.length} market${markets.length === 1 ? '' : 's'}` +
|
|
701
|
-
(search ? ` · search="${search}"` : '') +
|
|
702
|
-
` · status=${status ?? 'open'} · sort=${sort ?? 'volume'}`,
|
|
703
|
-
'',
|
|
704
|
-
];
|
|
705
|
-
markets.forEach((m, i) => {
|
|
706
|
-
// Kalshi quotes prices in cents (0–100). Surface them as a tight
|
|
707
|
-
// bid/ask so the agent can read implied probability at a glance.
|
|
708
|
-
const bid = m.yes_bid != null ? `${m.yes_bid}¢` : 'n/a';
|
|
709
|
-
const ask = m.yes_ask != null ? `${m.yes_ask}¢` : 'n/a';
|
|
710
|
-
const ticker = m.ticker ? ` · ticker=\`${m.ticker}\`` : '';
|
|
711
|
-
lines.push(`${i + 1}. **${m.title || m.ticker || 'untitled'}**${ticker}\n` +
|
|
712
|
-
` yes ${bid}/${ask} · vol: ${m.volume?.toLocaleString() ?? 'n/a'} · OI: ${m.open_interest?.toLocaleString() ?? 'n/a'}` +
|
|
713
|
-
(m.close_time ? ` · closes ${m.close_time.slice(0, 10)}` : ''));
|
|
714
|
-
});
|
|
715
|
-
lines.push('', `_$0.001 paid via x402._`);
|
|
716
|
-
return { output: lines.join('\n') };
|
|
717
|
-
}
|
|
718
|
-
case 'crossPlatform': {
|
|
719
|
-
const raw = await getWithPayment('/v1/pm/matching-markets/pairs', {
|
|
720
|
-
limit: cappedLimit,
|
|
721
|
-
}, ctx);
|
|
722
|
-
const pairs = unwrapList(raw);
|
|
723
|
-
if (pairs.length === 0) {
|
|
724
|
-
return { output: 'No matched market pairs available right now.' };
|
|
725
|
-
}
|
|
726
|
-
const lines = [
|
|
727
|
-
`## Cross-platform matched pairs — ${pairs.length}`,
|
|
728
|
-
'_Polymarket ↔ Kalshi equivalent markets. Use these to compare implied probabilities across venues._',
|
|
729
|
-
'',
|
|
730
|
-
];
|
|
731
|
-
pairs.forEach((p, i) => {
|
|
732
|
-
const sim = p.similarity != null ? ` · similarity ${formatPct(p.similarity, 0)}` : '';
|
|
733
|
-
lines.push(`${i + 1}. **Polymarket:** ${p.polymarket_question || '(untitled)'}\n` +
|
|
734
|
-
` **Kalshi:** ${p.kalshi_title || '(untitled)'}` +
|
|
735
|
-
(p.kalshi_ticker ? ` · ticker=\`${p.kalshi_ticker}\`` : '') +
|
|
736
|
-
sim);
|
|
737
|
-
});
|
|
738
|
-
lines.push('', `_$0.005 paid via x402._`);
|
|
739
|
-
return { output: lines.join('\n') };
|
|
740
|
-
}
|
|
741
|
-
default:
|
|
742
|
-
return {
|
|
743
|
-
output: `Error: unknown action "${action}". Use: searchAll, searchPolymarket, searchKalshi, crossPlatform, leaderboard, walletProfile, walletPnl, walletPositions, smartActivity, smartMoney`,
|
|
744
|
-
isError: true,
|
|
745
|
-
};
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
catch (err) {
|
|
749
|
-
return { output: `Error: ${err.message}`, isError: true };
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
export const predictionMarketCapability = {
|
|
753
|
-
spec: {
|
|
754
|
-
name: 'PredictionMarket',
|
|
755
|
-
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. ' +
|
|
756
|
-
'Actions: ' +
|
|
757
|
-
'`searchAll` (search markets across Polymarket+Kalshi+Limitless+Opinion+Predict.Fun in one call — $0.005), ' +
|
|
758
|
-
'`searchPolymarket` (Polymarket only, supports sort+status — $0.001), ' +
|
|
759
|
-
'`searchKalshi` (Kalshi only, supports sort+status — $0.001), ' +
|
|
760
|
-
'`crossPlatform` (matched market pairs across Polymarket+Kalshi for arbitrage / consensus — $0.005), ' +
|
|
761
|
-
'`leaderboard` (global top wallets by P&L on Polymarket — $0.001), ' +
|
|
762
|
-
'`walletProfile` (full Polymarket wallet profile — labels, scores, stats. Single address → /wallet/{addr}; comma-list → batch /wallets/profiles — $0.005), ' +
|
|
763
|
-
'`walletPnl` (single Polymarket wallet P&L summary + time series — $0.005), ' +
|
|
764
|
-
'`walletPositions` (single Polymarket wallet positions — open + historical with P&L per position — $0.005), ' +
|
|
765
|
-
'`smartActivity` (markets where high-P&L wallets are positioning right now — $0.005), ' +
|
|
766
|
-
'`smartMoney` (smart-money positioning on one Polymarket condition_id — $0.005). ' +
|
|
767
|
-
'Default routing: ' +
|
|
768
|
-
'"is there a market on X anywhere" → searchAll. ' +
|
|
769
|
-
'"top wallets / who is profitable / who should I follow on Polymarket" → leaderboard. ' +
|
|
770
|
-
'"analyze this wallet / can I copy this trader / copy trade / show me their P&L AND positions" → run walletProfile + walletPnl + walletPositions IN PARALLEL with the same address — three $0.005 calls give the full picture for $0.015. Do not Bash-curl Polymarket directly; the agent has paid tools for this. ' +
|
|
771
|
-
'"what are smart traders betting on right now" → smartActivity. ' +
|
|
772
|
-
'"show smart money on this specific Polymarket market" → smartMoney with conditionId. ' +
|
|
773
|
-
'"should I bet on X" → run searchPolymarket + searchKalshi in parallel and compare implied probabilities — divergence is the signal.',
|
|
774
|
-
input_schema: {
|
|
775
|
-
type: 'object',
|
|
776
|
-
properties: {
|
|
777
|
-
action: {
|
|
778
|
-
type: 'string',
|
|
779
|
-
enum: [
|
|
780
|
-
'searchAll',
|
|
781
|
-
'searchPolymarket',
|
|
782
|
-
'searchKalshi',
|
|
783
|
-
'crossPlatform',
|
|
784
|
-
'leaderboard',
|
|
785
|
-
'walletProfile',
|
|
786
|
-
'walletPnl',
|
|
787
|
-
'walletPositions',
|
|
788
|
-
'smartActivity',
|
|
789
|
-
'smartMoney',
|
|
790
|
-
],
|
|
791
|
-
description: 'Which prediction-market query to run. See tool description for cost per action.',
|
|
792
|
-
},
|
|
793
|
-
search: {
|
|
794
|
-
type: 'string',
|
|
795
|
-
description: 'Search query. Used by searchAll / searchPolymarket / searchKalshi / smartActivity. Optional for crossPlatform/leaderboard/walletProfile/walletPnl/walletPositions/smartMoney.',
|
|
796
|
-
},
|
|
797
|
-
status: {
|
|
798
|
-
type: 'string',
|
|
799
|
-
description: 'Polymarket: active | closed | archived (default active). Kalshi: open | closed (default open). Forwarded to searchAll where supported.',
|
|
800
|
-
},
|
|
801
|
-
sort: {
|
|
802
|
-
type: 'string',
|
|
803
|
-
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).',
|
|
804
|
-
},
|
|
805
|
-
limit: {
|
|
806
|
-
type: 'number',
|
|
807
|
-
description: `Max results (default ${DEFAULT_LIMIT}, hard cap ${MAX_LIMIT}).`,
|
|
808
|
-
},
|
|
809
|
-
wallets: {
|
|
810
|
-
type: 'string',
|
|
811
|
-
description: 'For walletProfile: a single Polymarket wallet address, or a comma-separated list of addresses for batch lookup.',
|
|
812
|
-
},
|
|
813
|
-
conditionId: {
|
|
814
|
-
type: 'string',
|
|
815
|
-
description: 'For smartMoney: Polymarket condition_id from searchPolymarket or smartActivity.',
|
|
816
|
-
},
|
|
817
|
-
granularity: {
|
|
818
|
-
type: 'string',
|
|
819
|
-
enum: ['day', 'week', 'month', 'year', 'all'],
|
|
820
|
-
description: 'For walletPnl: time bucket for the P&L series. Default day.',
|
|
821
|
-
},
|
|
822
|
-
},
|
|
823
|
-
required: ['action'],
|
|
824
|
-
},
|
|
825
|
-
},
|
|
826
|
-
execute,
|
|
827
|
-
concurrent: true,
|
|
828
|
-
};
|