@spfunctions/cli 1.7.13 → 1.7.16

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.
@@ -219,21 +219,37 @@ function createFooterBar(piTui) {
219
219
  exchangeOpen = null;
220
220
  cachedWidth;
221
221
  cachedLines;
222
+ isExplorer = false;
222
223
  setFromContext(ctx, positions) {
223
- this.thesisId = (ctx.thesisId || '').slice(0, 8);
224
- this.confidence = typeof ctx.confidence === 'number'
225
- ? Math.round(ctx.confidence * 100)
226
- : (typeof ctx.confidence === 'string' ? Math.round(parseFloat(ctx.confidence) * 100) : 0);
227
- this.confidenceDelta = ctx.lastEvaluation?.confidenceDelta
228
- ? Math.round(ctx.lastEvaluation.confidenceDelta * 100)
229
- : 0;
230
- this.edgeCount = (ctx.edges || []).length;
231
- const edges = ctx.edges || [];
232
- if (edges.length > 0) {
233
- const top = [...edges].sort((a, b) => Math.abs(b.edge || b.edgeSize || 0) - Math.abs(a.edge || a.edgeSize || 0))[0];
234
- const name = (top.market || top.marketTitle || top.marketId || '').slice(0, 20);
235
- const edge = top.edge || top.edgeSize || 0;
236
- this.topEdge = `${name} ${edge > 0 ? '+' : ''}${Math.round(edge)}\u00A2`;
224
+ if (ctx._explorerMode) {
225
+ this.isExplorer = true;
226
+ this.thesisId = 'Explorer';
227
+ this.confidence = 0;
228
+ this.confidenceDelta = 0;
229
+ this.edgeCount = (ctx.edges || []).length;
230
+ const edges = ctx.edges || [];
231
+ if (edges.length > 0) {
232
+ const top = [...edges].sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))[0];
233
+ this.topEdge = `${(top.title || '').slice(0, 20)} +${Math.round(top.edge)}¢`;
234
+ }
235
+ }
236
+ else {
237
+ this.isExplorer = false;
238
+ this.thesisId = (ctx.thesisId || '').slice(0, 8);
239
+ this.confidence = typeof ctx.confidence === 'number'
240
+ ? Math.round(ctx.confidence * 100)
241
+ : (typeof ctx.confidence === 'string' ? Math.round(parseFloat(ctx.confidence) * 100) : 0);
242
+ this.confidenceDelta = ctx.lastEvaluation?.confidenceDelta
243
+ ? Math.round(ctx.lastEvaluation.confidenceDelta * 100)
244
+ : 0;
245
+ this.edgeCount = (ctx.edges || []).length;
246
+ const edges = ctx.edges || [];
247
+ if (edges.length > 0) {
248
+ const top = [...edges].sort((a, b) => Math.abs(b.edge || b.edgeSize || 0) - Math.abs(a.edge || a.edgeSize || 0))[0];
249
+ const name = (top.market || top.marketTitle || top.marketId || '').slice(0, 20);
250
+ const edge = top.edge || top.edgeSize || 0;
251
+ this.topEdge = `${name} ${edge > 0 ? '+' : ''}${Math.round(edge)}\u00A2`;
252
+ }
237
253
  }
238
254
  if (positions && positions.length > 0) {
239
255
  this.positionCount = positions.length;
@@ -260,23 +276,39 @@ function createFooterBar(piTui) {
260
276
  if (this.cachedLines && this.cachedWidth === width)
261
277
  return this.cachedLines;
262
278
  this.cachedWidth = width;
263
- // Line 1: thesis info
264
- const id = C.emerald(this.thesisId);
265
- const arrow = this.confidenceDelta > 0 ? '\u25B2' : this.confidenceDelta < 0 ? '\u25BC' : '\u2500';
266
- const arrowColor = this.confidenceDelta > 0 ? C.emerald : this.confidenceDelta < 0 ? C.red : C.zinc600;
267
- const deltaStr = this.confidenceDelta !== 0 ? ` (${this.confidenceDelta > 0 ? '+' : ''}${this.confidenceDelta})` : '';
268
- const conf = arrowColor(`${arrow} ${this.confidence}%${deltaStr}`);
269
- let pnl = '';
270
- if (this.positionCount > 0) {
271
- const pnlStr = this.pnlDollars >= 0
272
- ? C.emerald(`+$${this.pnlDollars.toFixed(2)}`)
273
- : C.red(`-$${Math.abs(this.pnlDollars).toFixed(2)}`);
274
- pnl = C.zinc600(`${this.positionCount} pos `) + pnlStr;
275
- }
276
- const edges = C.zinc600(`${this.edgeCount} edges`);
277
- const top = this.topEdge ? C.zinc400(this.topEdge) : '';
279
+ // Line 1: thesis info (or explorer mode)
278
280
  const sep = C.zinc600(' \u2502 ');
279
- const line1Parts = [id, conf, pnl, edges, top].filter(Boolean);
281
+ let line1Parts;
282
+ if (this.isExplorer) {
283
+ const id = C.emerald(bold('Explorer'));
284
+ const edges = C.zinc600(`${this.edgeCount} public edges`);
285
+ const top = this.topEdge ? C.zinc400(this.topEdge) : '';
286
+ let pnl = '';
287
+ if (this.positionCount > 0) {
288
+ const pnlStr = this.pnlDollars >= 0
289
+ ? C.emerald(`+$${this.pnlDollars.toFixed(2)}`)
290
+ : C.red(`-$${Math.abs(this.pnlDollars).toFixed(2)}`);
291
+ pnl = C.zinc600(`${this.positionCount} pos `) + pnlStr;
292
+ }
293
+ line1Parts = [id, pnl, edges, top].filter(Boolean);
294
+ }
295
+ else {
296
+ const id = C.emerald(this.thesisId);
297
+ const arrow = this.confidenceDelta > 0 ? '\u25B2' : this.confidenceDelta < 0 ? '\u25BC' : '\u2500';
298
+ const arrowColor = this.confidenceDelta > 0 ? C.emerald : this.confidenceDelta < 0 ? C.red : C.zinc600;
299
+ const deltaStr = this.confidenceDelta !== 0 ? ` (${this.confidenceDelta > 0 ? '+' : ''}${this.confidenceDelta})` : '';
300
+ const conf = arrowColor(`${arrow} ${this.confidence}%${deltaStr}`);
301
+ let pnl = '';
302
+ if (this.positionCount > 0) {
303
+ const pnlStr = this.pnlDollars >= 0
304
+ ? C.emerald(`+$${this.pnlDollars.toFixed(2)}`)
305
+ : C.red(`-$${Math.abs(this.pnlDollars).toFixed(2)}`);
306
+ pnl = C.zinc600(`${this.positionCount} pos `) + pnlStr;
307
+ }
308
+ const edges = C.zinc600(`${this.edgeCount} edges`);
309
+ const top = this.topEdge ? C.zinc400(this.topEdge) : '';
310
+ line1Parts = [id, conf, pnl, edges, top].filter(Boolean);
311
+ }
280
312
  let line1 = C.bgZinc800(' ' + truncateToWidth(line1Parts.join(sep), width - 2, '') + ' ');
281
313
  const l1vw = visibleWidth(line1);
282
314
  if (l1vw < width)
@@ -393,14 +425,18 @@ function renderPositions(positions) {
393
425
  return lines.join('\n');
394
426
  }
395
427
  // ─── Thesis selector (arrow keys + enter, like Claude Code) ─────────────────
396
- async function selectThesis(theses) {
428
+ async function selectThesis(theses, includeExplorer = false) {
397
429
  return new Promise((resolve) => {
398
430
  let selected = 0;
399
- const items = theses.map((t) => {
431
+ const items = [];
432
+ if (includeExplorer) {
433
+ items.push({ id: '_explorer', conf: -1, title: 'Explorer mode — no thesis, full market access' });
434
+ }
435
+ for (const t of theses) {
400
436
  const conf = typeof t.confidence === 'number' ? Math.round(t.confidence * 100) : 0;
401
437
  const title = (t.rawThesis || t.thesis || t.title || '').slice(0, 55);
402
- return { id: t.id, conf, title };
403
- });
438
+ items.push({ id: t.id, conf, title });
439
+ }
404
440
  const write = process.stdout.write.bind(process.stdout);
405
441
  // Use alternate screen buffer for clean rendering (like Claude Code)
406
442
  write('\x1b[?1049h'); // enter alternate screen
@@ -412,10 +448,16 @@ async function selectThesis(theses) {
412
448
  const item = items[i];
413
449
  const sel = i === selected;
414
450
  const cursor = sel ? '\x1b[38;2;16;185;129m › \x1b[39m' : ' ';
415
- const id = sel ? `\x1b[38;2;16;185;129m${item.id.slice(0, 8)}\x1b[39m` : `\x1b[38;2;55;55;60m${item.id.slice(0, 8)}\x1b[39m`;
416
- const conf = `\x1b[38;2;55;55;60m${item.conf}%\x1b[39m`;
417
- const title = sel ? `\x1b[38;2;160;160;165m${item.title}\x1b[39m` : `\x1b[38;2;80;80;88m${item.title}\x1b[39m`;
418
- write(`${cursor}${id} ${conf} ${title}\n`);
451
+ if (item.id === '_explorer') {
452
+ const title = sel ? `\x1b[38;2;16;185;129m${item.title}\x1b[39m` : `\x1b[38;2;80;80;88m${item.title}\x1b[39m`;
453
+ write(`${cursor}${title}\n`);
454
+ }
455
+ else {
456
+ const id = sel ? `\x1b[38;2;16;185;129m${item.id.slice(0, 8)}\x1b[39m` : `\x1b[38;2;55;55;60m${item.id.slice(0, 8)}\x1b[39m`;
457
+ const conf = `\x1b[38;2;55;55;60m${item.conf}%\x1b[39m`;
458
+ const title = sel ? `\x1b[38;2;160;160;165m${item.title}\x1b[39m` : `\x1b[38;2;80;80;88m${item.title}\x1b[39m`;
459
+ write(`${cursor}${id} ${conf} ${title}\n`);
460
+ }
419
461
  }
420
462
  write(`\n \x1b[38;2;55;55;60m↑↓ navigate · enter select\x1b[39m`);
421
463
  }
@@ -507,36 +549,35 @@ async function agentCommand(thesisId, opts) {
507
549
  }
508
550
  const sfClient = new client_js_1.SFClient();
509
551
  // ── Resolve thesis ID (interactive selection if needed) ─────────────────────
510
- let resolvedThesisId = thesisId;
552
+ let resolvedThesisId = thesisId || null;
553
+ let explorerMode = false;
511
554
  if (!resolvedThesisId) {
512
- const data = await sfClient.listTheses();
513
- const theses = (data.theses || data);
514
- const active = theses.filter((t) => t.status === 'active');
555
+ let active = [];
556
+ try {
557
+ const data = await sfClient.listTheses();
558
+ const theses = (data.theses || data);
559
+ active = theses.filter((t) => t.status === 'active');
560
+ }
561
+ catch {
562
+ // No API key or network error — explorer mode
563
+ active = [];
564
+ }
515
565
  if (active.length === 0) {
516
- if (!process.stdin.isTTY) {
517
- console.error('No active thesis. Create one first: sf create "..."');
518
- process.exit(1);
519
- }
520
- // No theses — offer to create one
521
- console.log('\n No active theses found.\n');
522
- const readline = await import('readline');
523
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
524
- const answer = await new Promise(resolve => rl.question(' Enter a thesis to create (or press Enter to exit):\n > ', resolve));
525
- rl.close();
526
- if (!answer.trim()) {
527
- process.exit(0);
528
- }
529
- console.log('\n Creating thesis...\n');
530
- const result = await sfClient.createThesis(answer.trim(), true);
531
- resolvedThesisId = result.id;
532
- console.log(` ✓ Created: ${result.id?.slice(0, 8)}\n`);
566
+ // No theses — go straight to explorer mode
567
+ explorerMode = true;
533
568
  }
534
569
  else if (active.length === 1) {
535
570
  resolvedThesisId = active[0].id;
536
571
  }
537
572
  else if (process.stdin.isTTY && !opts?.noTui) {
538
- // Multiple theses — interactive arrow key selector (TUI only)
539
- resolvedThesisId = await selectThesis(active);
573
+ // Multiple theses — interactive selector with explorer option at top
574
+ const selected = await selectThesis(active, true);
575
+ if (selected === '_explorer') {
576
+ explorerMode = true;
577
+ }
578
+ else {
579
+ resolvedThesisId = selected;
580
+ }
540
581
  }
541
582
  else {
542
583
  // Non-interactive (--plain, telegram, piped) — use first active
@@ -544,10 +585,18 @@ async function agentCommand(thesisId, opts) {
544
585
  }
545
586
  }
546
587
  // ── Fetch initial context ──────────────────────────────────────────────────
547
- let latestContext = await sfClient.getContext(resolvedThesisId);
588
+ let latestContext;
589
+ if (explorerMode) {
590
+ const { fetchGlobalContext } = await import('../client.js');
591
+ latestContext = await fetchGlobalContext();
592
+ latestContext._explorerMode = true;
593
+ }
594
+ else {
595
+ latestContext = await sfClient.getContext(resolvedThesisId);
596
+ }
548
597
  // ── Branch: plain-text mode ────────────────────────────────────────────────
549
598
  if (opts?.noTui) {
550
- return runPlainTextAgent({ openrouterKey, sfClient, resolvedThesisId: resolvedThesisId, latestContext, useProxy, llmBaseUrl, sfApiKey, sfApiUrl, opts });
599
+ return runPlainTextAgent({ openrouterKey, sfClient, resolvedThesisId: resolvedThesisId || '_explorer', latestContext, useProxy, llmBaseUrl, sfApiKey, sfApiUrl, opts });
551
600
  }
552
601
  // ── Dynamic imports (all ESM-only packages) ────────────────────────────────
553
602
  const piTui = await import('@mariozechner/pi-tui');
@@ -1399,7 +1448,7 @@ async function agentCommand(thesisId, opts) {
1399
1448
  {
1400
1449
  name: 'create_thesis',
1401
1450
  label: 'Create Thesis',
1402
- description: 'Create a new thesis from a raw thesis statement. Returns the thesis ID, confidence, node count, and edge count.',
1451
+ description: 'Create a new thesis from a raw thesis statement. Returns the thesis ID, confidence, node count, and edge count. In explorer mode, this automatically transitions to thesis mode.',
1403
1452
  parameters: Type.Object({
1404
1453
  rawThesis: Type.String({ description: 'The raw thesis statement to create' }),
1405
1454
  webhookUrl: Type.Optional(Type.String({ description: 'Optional webhook URL for notifications' })),
@@ -1410,8 +1459,21 @@ async function agentCommand(thesisId, opts) {
1410
1459
  const nodeCount = thesis.causalTree?.nodes?.length || 0;
1411
1460
  const edgeCount = (thesis.edges || []).length;
1412
1461
  const confidence = typeof thesis.confidence === 'number' ? Math.round(thesis.confidence * 100) : 0;
1462
+ // ── Auto-transition from explorer to thesis mode ──────────────────
1463
+ if (explorerMode && thesis.id) {
1464
+ explorerMode = false;
1465
+ resolvedThesisId = thesis.id;
1466
+ try {
1467
+ latestContext = await sfClient.getContext(thesis.id);
1468
+ const newPrompt = buildSystemPrompt(latestContext);
1469
+ agent.setSystemPrompt(newPrompt);
1470
+ footerBar.setFromContext(latestContext, initialPositions || undefined);
1471
+ tui.requestRender();
1472
+ }
1473
+ catch { /* context fetch failed, still switch */ }
1474
+ }
1413
1475
  return {
1414
- content: [{ type: 'text', text: `Thesis created.\nID: ${thesis.id}\nConfidence: ${confidence}%\nNodes: ${nodeCount}\nEdges: ${edgeCount}` }],
1476
+ content: [{ type: 'text', text: `Thesis created.\nID: ${thesis.id}\nConfidence: ${confidence}%\nNodes: ${nodeCount}\nEdges: ${edgeCount}\n\nHeartbeat engine is now monitoring this thesis 24/7. Use /switch ${thesis.id?.slice(0, 8)} to focus on it.` }],
1415
1477
  details: {},
1416
1478
  };
1417
1479
  },
@@ -1588,6 +1650,37 @@ async function agentCommand(thesisId, opts) {
1588
1650
  const data = await sfClient.getXAccount(params.username, { hours: params.hours });
1589
1651
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1590
1652
  },
1653
+ }, {
1654
+ name: 'heartbeat_config',
1655
+ label: 'Heartbeat Config',
1656
+ description: 'View or update heartbeat settings for a thesis: scan intervals, model tier, budget cap, pause/resume. Also shows this month\'s cost breakdown.',
1657
+ parameters: Type.Object({
1658
+ thesisId: Type.String({ description: 'Thesis ID' }),
1659
+ newsIntervalMin: Type.Optional(Type.Number({ description: 'News scan interval in minutes (15-1440)' })),
1660
+ xIntervalMin: Type.Optional(Type.Number({ description: 'X scan interval in minutes (60-1440)' })),
1661
+ evalModelTier: Type.Optional(Type.String({ description: 'Eval model: cheap, medium, or heavy' })),
1662
+ monthlyBudgetUsd: Type.Optional(Type.Number({ description: 'Monthly budget cap in USD (0 = unlimited)' })),
1663
+ paused: Type.Optional(Type.Boolean({ description: 'Pause (true) or resume (false) heartbeat' })),
1664
+ }),
1665
+ execute: async (_toolCallId, params) => {
1666
+ const hasUpdates = params.newsIntervalMin || params.xIntervalMin || params.evalModelTier || params.monthlyBudgetUsd !== undefined || params.paused !== undefined;
1667
+ if (hasUpdates) {
1668
+ const updates = {};
1669
+ if (params.newsIntervalMin)
1670
+ updates.newsIntervalMin = params.newsIntervalMin;
1671
+ if (params.xIntervalMin)
1672
+ updates.xIntervalMin = params.xIntervalMin;
1673
+ if (params.evalModelTier)
1674
+ updates.evalModelTier = params.evalModelTier;
1675
+ if (params.monthlyBudgetUsd !== undefined)
1676
+ updates.monthlyBudgetUsd = params.monthlyBudgetUsd;
1677
+ if (params.paused !== undefined)
1678
+ updates.paused = params.paused;
1679
+ await sfClient.updateHeartbeatConfig(params.thesisId, updates);
1680
+ }
1681
+ const data = await sfClient.getHeartbeatConfig(params.thesisId);
1682
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1683
+ },
1591
1684
  });
1592
1685
  // ── Trading tools (conditional on tradingEnabled) ──────────────────────────
1593
1686
  const config = (0, config_js_1.loadConfig)();
@@ -1727,6 +1820,9 @@ Don't answer a complex question with a single tool call.
1727
1820
  ### Social signal research
1728
1821
  Use search_x to check X/Twitter sentiment on any topic — especially useful for geopolitical events, macro shifts, and breaking news that moves prediction markets. Use x_volume to detect discussion spikes (velocity > 1 = increasing attention). Use x_account to track specific analysts or officials.
1729
1822
 
1823
+ ### Heartbeat config
1824
+ Use heartbeat_config to view or adjust per-thesis heartbeat settings: news/X scan intervals, evaluation model tier (cheap/medium/heavy), monthly budget cap, pause/resume. Also shows this month's cost breakdown (LLM calls, search calls, tokens, spend). Useful when the user asks about costs, wants to slow down/speed up monitoring, or if you detect budget overrun.
1825
+
1730
1826
  ### Conditional rules
1731
1827
  - Portfolio/positions questions: flag correlated exposure — positions sharing upstream causal nodes are not independent bets.
1732
1828
  - No catalyst visible within 30 days + edge not improving: flag "stale capital risk."
@@ -1777,7 +1873,58 @@ ${edgesSummary}
1777
1873
 
1778
1874
  ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation.summary.slice(0, 300)}` : ''}`;
1779
1875
  }
1780
- const systemPrompt = buildSystemPrompt(latestContext);
1876
+ function buildExplorerPrompt(ctx) {
1877
+ const config = (0, config_js_1.loadConfig)();
1878
+ const theseCount = ctx.theses?.length || 0;
1879
+ const edgeCount = ctx.edges?.length || 0;
1880
+ const topEdges = (ctx.edges || [])
1881
+ .sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
1882
+ .slice(0, 5)
1883
+ .map((e) => ` ${(e.title || '').slice(0, 40)} | ${e.venue || 'kalshi'} | mkt ${e.price}¢ | edge +${e.edge}`)
1884
+ .join('\n') || ' (no edges)';
1885
+ return `You are a prediction market research assistant with access to live data across Kalshi, Polymarket, X/Twitter, and traditional markets.
1886
+
1887
+ You are in EXPLORER MODE — not bound to any specific thesis. Help the user research, compare, and understand prediction market data across all sources.
1888
+
1889
+ ## What you can do
1890
+ - Search and compare markets across Kalshi and Polymarket (scan_markets)
1891
+ - Answer questions with live market data + LLM synthesis (query)
1892
+ - Check traditional market prices — SPY, VIX, gold, oil, bonds (get_markets)
1893
+ - Browse public theses and their edges (explore_public)
1894
+ - Search X/Twitter for sentiment and breaking news (search_x, x_volume, x_news)
1895
+ - Check orderbook depth and liquidity (inspect_book, get_liquidity)
1896
+ - View user positions across venues (get_positions)
1897
+ - Create a new thesis when the user forms a view (create_thesis)
1898
+
1899
+ ## CRITICAL: Thesis creation transition
1900
+ When the user expresses a market view worth tracking — explicitly ("create a thesis") or implicitly ("I think oil stays above $100", "the war won't end soon") — use create_thesis to create it. After creation, tell the user: "Thesis created. The heartbeat engine is now monitoring this 24/7. Use /switch <id> to focus on it."
1901
+
1902
+ ## Your analytical framework
1903
+ Edge = thesis price - market price. Positive = market underprices.
1904
+ Edge types: "consensus_gap" (real disagreement), "attention_gap" (no real pricing), "timing_gap" (market lags), "risk_premium" (settlement risk).
1905
+ Price reliability: depth >= 500 = consensus. depth < 100 = unreliable. spread > 5¢ = noisy.
1906
+ Always state contract expiry and next catalyst. No catalyst = capital lock risk.
1907
+
1908
+ ## Your behavioral rules
1909
+ - Be concise. Use tools for fresh data. Don't guess prices.
1910
+ - You do NOT know the user's positions at start. Call get_positions before discussing trades.
1911
+ - If user mentions news, offer to create a thesis or inject a signal if one exists.
1912
+ - Don't end with "anything else?"
1913
+ - Use Chinese if user writes Chinese, English if English.
1914
+ - Prices in cents (¢). P&L in dollars ($).
1915
+
1916
+ ## Trading status
1917
+ ${config.tradingEnabled ? 'Trading is ENABLED.' : 'Trading is DISABLED. Tell user: sf setup --enable-trading'}
1918
+
1919
+ ## Current market snapshot
1920
+ Public theses tracked: ${theseCount}
1921
+ Top edges across all public theses:
1922
+ ${topEdges}
1923
+ `;
1924
+ }
1925
+ const systemPrompt = explorerMode
1926
+ ? buildExplorerPrompt(latestContext)
1927
+ : buildSystemPrompt(latestContext);
1781
1928
  // ── Create Agent ───────────────────────────────────────────────────────────
1782
1929
  const agent = new Agent({
1783
1930
  initialState: {
@@ -1796,7 +1943,7 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
1796
1943
  // ── Session restore ────────────────────────────────────────────────────────
1797
1944
  let sessionRestored = false;
1798
1945
  if (!opts?.newSession) {
1799
- const saved = loadSession(resolvedThesisId);
1946
+ const saved = loadSession(resolvedThesisId || '_explorer');
1800
1947
  if (saved?.messages?.length > 0) {
1801
1948
  try {
1802
1949
  // Clean corrupted messages: empty content, missing role, broken alternation
@@ -1837,7 +1984,7 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
1837
1984
  try {
1838
1985
  const msgs = agent.state.messages;
1839
1986
  if (msgs.length > 0) {
1840
- saveSession(resolvedThesisId, currentModelName, msgs);
1987
+ saveSession(resolvedThesisId || '_explorer', currentModelName, msgs);
1841
1988
  }
1842
1989
  }
1843
1990
  catch { /* best-effort save */ }
@@ -1981,29 +2128,58 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
1981
2128
  }
1982
2129
  case '/tree': {
1983
2130
  addSpacer();
1984
- // Refresh context first
1985
- try {
1986
- latestContext = await sfClient.getContext(resolvedThesisId);
1987
- addSystemText(C.zinc200(bold('Causal Tree')) + '\n' + renderCausalTree(latestContext, piTui));
2131
+ if (explorerMode) {
2132
+ addSystemText(C.zinc400('No thesis selected. Use /switch <id> to pick one, or ask me to create one.'));
1988
2133
  }
1989
- catch (err) {
1990
- addSystemText(C.red(`Error: ${err.message}`));
2134
+ else {
2135
+ try {
2136
+ latestContext = await sfClient.getContext(resolvedThesisId);
2137
+ addSystemText(C.zinc200(bold('Causal Tree')) + '\n' + renderCausalTree(latestContext, piTui));
2138
+ }
2139
+ catch (err) {
2140
+ addSystemText(C.red(`Error: ${err.message}`));
2141
+ }
1991
2142
  }
1992
2143
  addSpacer();
1993
2144
  return true;
1994
2145
  }
1995
2146
  case '/edges': {
1996
2147
  addSpacer();
1997
- try {
1998
- latestContext = await sfClient.getContext(resolvedThesisId);
1999
- // Attach cached positions for display
2000
- if (cachedPositions) {
2001
- latestContext._positions = cachedPositions;
2148
+ if (explorerMode) {
2149
+ // Show global public edges
2150
+ try {
2151
+ const { fetchGlobalContext } = await import('../client.js');
2152
+ const global = await fetchGlobalContext();
2153
+ const edges = (global.edges || []).sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge)).slice(0, 10);
2154
+ if (edges.length === 0) {
2155
+ addSystemText(C.zinc400('No public edges available.'));
2156
+ }
2157
+ else {
2158
+ const lines = edges.map((e) => {
2159
+ const name = (e.title || '').slice(0, 35).padEnd(35);
2160
+ const venue = (e.venue || 'kalshi').padEnd(5);
2161
+ const mkt = String(Math.round(e.price || 0)).padStart(3) + '¢';
2162
+ const edge = '+' + Math.round(e.edge || 0);
2163
+ return ` ${C.zinc400(name)} ${C.zinc600(venue)} ${C.zinc400(mkt)} ${C.emerald(edge.padStart(4))}`;
2164
+ }).join('\n');
2165
+ addSystemText(C.zinc200(bold('Public Edges')) + '\n' + lines);
2166
+ }
2167
+ }
2168
+ catch (err) {
2169
+ addSystemText(C.red(`Error: ${err.message}`));
2002
2170
  }
2003
- addSystemText(C.zinc200(bold('Edges')) + '\n' + renderEdges(latestContext, piTui));
2004
2171
  }
2005
- catch (err) {
2006
- addSystemText(C.red(`Error: ${err.message}`));
2172
+ else {
2173
+ try {
2174
+ latestContext = await sfClient.getContext(resolvedThesisId);
2175
+ if (cachedPositions) {
2176
+ latestContext._positions = cachedPositions;
2177
+ }
2178
+ addSystemText(C.zinc200(bold('Edges')) + '\n' + renderEdges(latestContext, piTui));
2179
+ }
2180
+ catch (err) {
2181
+ addSystemText(C.red(`Error: ${err.message}`));
2182
+ }
2007
2183
  }
2008
2184
  addSpacer();
2009
2185
  return true;
@@ -2034,6 +2210,11 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
2034
2210
  }
2035
2211
  case '/eval': {
2036
2212
  addSpacer();
2213
+ if (explorerMode) {
2214
+ addSystemText(C.zinc400('No thesis selected. Use /switch <id> to pick one, or ask me to create one.'));
2215
+ addSpacer();
2216
+ return true;
2217
+ }
2037
2218
  addSystemText(C.zinc600('Triggering evaluation...'));
2038
2219
  tui.requestRender();
2039
2220
  try {
@@ -2499,6 +2680,34 @@ Output a structured summary. Be concise but preserve every important detail —
2499
2680
  // ── Welcome dashboard builder ────────────────────────────────────────────
2500
2681
  function buildWelcomeDashboard(ctx, positions) {
2501
2682
  const lines = [];
2683
+ // ── Explorer mode welcome ──────────────────────────────────────────────
2684
+ if (ctx._explorerMode) {
2685
+ const edgeCount = ctx.edges?.length || 0;
2686
+ const theseCount = ctx.theses?.length || 0;
2687
+ lines.push(C.zinc600('\u2500'.repeat(55)));
2688
+ lines.push(' ' + C.emerald(bold('Explorer mode')) + C.zinc600(' — full market access, no thesis'));
2689
+ lines.push(' ' + C.zinc600(`${theseCount} public theses \u2502 ${edgeCount} edges tracked`));
2690
+ lines.push(C.zinc600('\u2500'.repeat(55)));
2691
+ // Show top public edges
2692
+ const edges = ctx.edges || [];
2693
+ if (edges.length > 0) {
2694
+ const sorted = [...edges].sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge)).slice(0, 5);
2695
+ lines.push(' ' + C.zinc400(bold('TOP PUBLIC EDGES')) + C.zinc600(' mkt edge'));
2696
+ for (const e of sorted) {
2697
+ const name = (e.title || '').slice(0, 30).padEnd(30);
2698
+ const mkt = String(Math.round(e.price || 0)).padStart(3) + '\u00A2';
2699
+ const edge = e.edge || 0;
2700
+ const edgeStr = '+' + Math.round(edge);
2701
+ const edgeColor = Math.abs(edge) >= 15 ? C.emerald : Math.abs(edge) >= 8 ? C.amber : C.zinc400;
2702
+ lines.push(` ${C.zinc400(name)} ${C.zinc400(mkt)} ${edgeColor(edgeStr.padStart(4))}`);
2703
+ }
2704
+ }
2705
+ lines.push(C.zinc600('\u2500'.repeat(55)));
2706
+ lines.push(' ' + C.zinc600('Ask anything, or describe a view to create a thesis.'));
2707
+ lines.push(C.zinc600('\u2500'.repeat(55)));
2708
+ return lines.join('\n');
2709
+ }
2710
+ // ── Thesis mode welcome (existing) ────────────────────────────────────
2502
2711
  const thesisText = ctx.thesis || ctx.rawThesis || 'N/A';
2503
2712
  const truncated = thesisText.length > 100 ? thesisText.slice(0, 100) + '...' : thesisText;
2504
2713
  const conf = typeof ctx.confidence === 'number'
@@ -2596,25 +2805,26 @@ Output a structured summary. Be concise but preserve every important detail —
2596
2805
  }
2597
2806
  // absDelta === 0: truly nothing changed, stay silent
2598
2807
  }
2599
- // ── Start heartbeat polling ───────────────────────────────────────────────
2600
- heartbeatPollTimer = setInterval(async () => {
2601
- try {
2602
- const delta = await sfClient.getChanges(resolvedThesisId, lastPollTimestamp);
2603
- lastPollTimestamp = new Date().toISOString();
2604
- if (!delta.changed)
2605
- return;
2606
- if (isProcessing || pendingPrompt) {
2607
- // Agent is busy — queue for delivery after agent_end
2608
- pendingHeartbeatDelta = delta;
2808
+ // ── Start heartbeat polling (thesis mode only) ──────────────────────────
2809
+ if (!explorerMode)
2810
+ heartbeatPollTimer = setInterval(async () => {
2811
+ try {
2812
+ const delta = await sfClient.getChanges(resolvedThesisId, lastPollTimestamp);
2813
+ lastPollTimestamp = new Date().toISOString();
2814
+ if (!delta.changed)
2815
+ return;
2816
+ if (isProcessing || pendingPrompt) {
2817
+ // Agent is busy — queue for delivery after agent_end
2818
+ pendingHeartbeatDelta = delta;
2819
+ }
2820
+ else {
2821
+ handleHeartbeatDelta(delta);
2822
+ }
2609
2823
  }
2610
- else {
2611
- handleHeartbeatDelta(delta);
2824
+ catch {
2825
+ // Silent — don't spam errors from background polling
2612
2826
  }
2613
- }
2614
- catch {
2615
- // Silent — don't spam errors from background polling
2616
- }
2617
- }, 60_000); // every 60 seconds
2827
+ }, 60_000); // every 60 seconds
2618
2828
  // ── Start TUI ──────────────────────────────────────────────────────────────
2619
2829
  tui.start();
2620
2830
  }
@@ -3345,6 +3555,36 @@ async function runPlainTextAgent(params) {
3345
3555
  const data = await sfClient.getXAccount(p.username, { hours: p.hours });
3346
3556
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3347
3557
  },
3558
+ }, {
3559
+ name: 'heartbeat_config', label: 'Heartbeat Config',
3560
+ description: 'View or update heartbeat settings: scan intervals, model tier, budget cap, pause/resume. Shows cost breakdown.',
3561
+ parameters: Type.Object({
3562
+ thesisId: Type.String({ description: 'Thesis ID' }),
3563
+ newsIntervalMin: Type.Optional(Type.Number({ description: 'News scan interval (15-1440 min)' })),
3564
+ xIntervalMin: Type.Optional(Type.Number({ description: 'X scan interval (60-1440 min)' })),
3565
+ evalModelTier: Type.Optional(Type.String({ description: 'cheap, medium, or heavy' })),
3566
+ monthlyBudgetUsd: Type.Optional(Type.Number({ description: 'Monthly budget (0 = unlimited)' })),
3567
+ paused: Type.Optional(Type.Boolean({ description: 'Pause or resume heartbeat' })),
3568
+ }),
3569
+ execute: async (_id, p) => {
3570
+ const hasUp = p.newsIntervalMin || p.xIntervalMin || p.evalModelTier || p.monthlyBudgetUsd !== undefined || p.paused !== undefined;
3571
+ if (hasUp) {
3572
+ const u = {};
3573
+ if (p.newsIntervalMin)
3574
+ u.newsIntervalMin = p.newsIntervalMin;
3575
+ if (p.xIntervalMin)
3576
+ u.xIntervalMin = p.xIntervalMin;
3577
+ if (p.evalModelTier)
3578
+ u.evalModelTier = p.evalModelTier;
3579
+ if (p.monthlyBudgetUsd !== undefined)
3580
+ u.monthlyBudgetUsd = p.monthlyBudgetUsd;
3581
+ if (p.paused !== undefined)
3582
+ u.paused = p.paused;
3583
+ await sfClient.updateHeartbeatConfig(p.thesisId, u);
3584
+ }
3585
+ const data = await sfClient.getHeartbeatConfig(p.thesisId);
3586
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3587
+ },
3348
3588
  });
3349
3589
  // ── Trading tools (conditional on tradingEnabled) for plain mode ──────────
3350
3590
  const config = (0, config_js_1.loadConfig)();
@@ -3423,17 +3663,49 @@ async function runPlainTextAgent(params) {
3423
3663
  }
3424
3664
  // ── System prompt ─────────────────────────────────────────────────────────
3425
3665
  const ctx = latestContext;
3426
- const edgesSummary = ctx.edges
3427
- ?.sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
3428
- .slice(0, 5)
3429
- .map((e) => ` ${(e.market || '').slice(0, 40)} | ${e.venue || 'kalshi'} | mkt ${e.marketPrice}\u00A2 | edge ${e.edge > 0 ? '+' : ''}${e.edge}`)
3430
- .join('\n') || ' (no edges)';
3431
- const nodesSummary = ctx.causalTree?.nodes
3432
- ?.filter((n) => n.depth === 0)
3433
- .map((n) => ` ${n.id} ${(n.label || '').slice(0, 40)} \u2014 ${Math.round(n.probability * 100)}%`)
3434
- .join('\n') || ' (no causal tree)';
3435
- const conf = typeof ctx.confidence === 'number' ? Math.round(ctx.confidence * 100) : 0;
3436
- const systemPrompt = `You are a prediction market trading assistant. Your job is not to please the user — it is to help them see reality clearly and make correct trading decisions.
3666
+ const isExplorerPlain = ctx._explorerMode || resolvedThesisId === '_explorer';
3667
+ let systemPrompt;
3668
+ if (isExplorerPlain) {
3669
+ const topEdges = (ctx.edges || [])
3670
+ .sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
3671
+ .slice(0, 5)
3672
+ .map((e) => ` ${(e.title || '').slice(0, 40)} | ${e.venue || 'kalshi'} | +${e.edge}`)
3673
+ .join('\n') || ' (no edges)';
3674
+ systemPrompt = `You are a prediction market research assistant in EXPLORER MODE — not bound to any thesis.
3675
+
3676
+ ## What you can do
3677
+ - query: LLM-enhanced market search
3678
+ - scan_markets: search Kalshi + Polymarket
3679
+ - get_markets: traditional markets (SPY, VIX, gold, oil)
3680
+ - explore_public: browse public theses
3681
+ - search_x, x_volume, x_news: X/Twitter signals
3682
+ - get_positions: portfolio positions
3683
+ - create_thesis: create a thesis when user forms a view
3684
+
3685
+ ## CRITICAL: When the user expresses a view worth tracking, use create_thesis. After creation, confirm and continue with the new thesis context.
3686
+
3687
+ ## Rules
3688
+ - Be concise. Use tools for fresh data.
3689
+ - Use Chinese if user writes Chinese, English if English.
3690
+ - Prices in cents (¢). P&L in dollars ($).
3691
+ ${config.tradingEnabled ? '- Trading ENABLED.' : '- Trading DISABLED.'}
3692
+
3693
+ ## Market snapshot
3694
+ Public edges:
3695
+ ${topEdges}`;
3696
+ }
3697
+ else {
3698
+ const edgesSummary = ctx.edges
3699
+ ?.sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
3700
+ .slice(0, 5)
3701
+ .map((e) => ` ${(e.market || '').slice(0, 40)} | ${e.venue || 'kalshi'} | mkt ${e.marketPrice}\u00A2 | edge ${e.edge > 0 ? '+' : ''}${e.edge}`)
3702
+ .join('\n') || ' (no edges)';
3703
+ const nodesSummary = ctx.causalTree?.nodes
3704
+ ?.filter((n) => n.depth === 0)
3705
+ .map((n) => ` ${n.id} ${(n.label || '').slice(0, 40)} \u2014 ${Math.round(n.probability * 100)}%`)
3706
+ .join('\n') || ' (no causal tree)';
3707
+ const conf = typeof ctx.confidence === 'number' ? Math.round(ctx.confidence * 100) : 0;
3708
+ systemPrompt = `You are a prediction market trading assistant. Your job is not to please the user — it is to help them see reality clearly and make correct trading decisions.
3437
3709
 
3438
3710
  ## Framework
3439
3711
  Edge = thesis price - market price. Positive = market underprices. executableEdge = edge minus spread.
@@ -3449,6 +3721,7 @@ Always state contract expiry and next catalyst. No catalyst = flag capital lock
3449
3721
 
3450
3722
  For complex questions, chain: get_context -> inspect_book -> get_liquidity -> web_search -> search_x -> synthesize.
3451
3723
  Use search_x for social sentiment on any topic. Use x_volume to detect discussion spikes. Use x_account to track key people.
3724
+ Use heartbeat_config to view/adjust scan intervals, model tier, budget cap, or check cost breakdown.
3452
3725
 
3453
3726
  Flag correlated exposure across positions sharing upstream nodes. If nothing to do, say so.
3454
3727
 
@@ -3475,6 +3748,7 @@ Top edges:
3475
3748
  ${edgesSummary}
3476
3749
 
3477
3750
  ${ctx.lastEvaluation?.summary ? `Latest eval: ${ctx.lastEvaluation.summary.slice(0, 300)}` : ''}`;
3751
+ }
3478
3752
  // ── Create agent ──────────────────────────────────────────────────────────
3479
3753
  const agent = new Agent({
3480
3754
  initialState: { systemPrompt, model, tools, thinkingLevel: 'off' },
@@ -3517,11 +3791,19 @@ ${ctx.lastEvaluation?.summary ? `Latest eval: ${ctx.lastEvaluation.summary.slice
3517
3791
  }
3518
3792
  });
3519
3793
  // ── Welcome ───────────────────────────────────────────────────────────────
3520
- const thesisText = ctx.thesis || ctx.rawThesis || 'N/A';
3521
- console.log(`SF Agent — ${resolvedThesisId.slice(0, 8)} | ${conf}% | ${currentModelName}`);
3522
- console.log(`Thesis: ${thesisText.length > 100 ? thesisText.slice(0, 100) + '...' : thesisText}`);
3523
- console.log(`Edges: ${(ctx.edges || []).length} | Status: ${ctx.status}`);
3524
- console.log('Type /help for commands, /exit to quit.\n');
3794
+ if (isExplorerPlain) {
3795
+ console.log(`SF Agent — Explorer mode | ${currentModelName}`);
3796
+ console.log(`Public edges: ${(ctx.edges || []).length}`);
3797
+ console.log('Ask anything about prediction markets. Type /help for commands, /exit to quit.\n');
3798
+ }
3799
+ else {
3800
+ const thesisText = ctx.thesis || ctx.rawThesis || 'N/A';
3801
+ const plainConf = typeof ctx.confidence === 'number' ? Math.round(ctx.confidence * 100) : 0;
3802
+ console.log(`SF Agent — ${resolvedThesisId.slice(0, 8)} | ${plainConf}% | ${currentModelName}`);
3803
+ console.log(`Thesis: ${thesisText.length > 100 ? thesisText.slice(0, 100) + '...' : thesisText}`);
3804
+ console.log(`Edges: ${(ctx.edges || []).length} | Status: ${ctx.status}`);
3805
+ console.log('Type /help for commands, /exit to quit.\n');
3806
+ }
3525
3807
  // ── REPL loop ─────────────────────────────────────────────────────────────
3526
3808
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' });
3527
3809
  rl.prompt();
@@ -0,0 +1,20 @@
1
+ /**
2
+ * sf heartbeat <thesisId> — View/update per-thesis heartbeat config & costs.
3
+ *
4
+ * Examples:
5
+ * sf heartbeat abc123 — show config + costs
6
+ * sf heartbeat abc123 --news-interval 30 — set news scan to 30 min
7
+ * sf heartbeat abc123 --model heavy — use heavy model for eval
8
+ * sf heartbeat abc123 --budget 5 — $5/month cap
9
+ * sf heartbeat abc123 --pause — pause heartbeat
10
+ * sf heartbeat abc123 --resume — resume heartbeat
11
+ */
12
+ import type { SFClient } from '../client.js';
13
+ export declare function heartbeatCommand(sfClient: SFClient, thesisId: string, opts: {
14
+ newsInterval?: string;
15
+ xInterval?: string;
16
+ model?: string;
17
+ budget?: string;
18
+ pause?: boolean;
19
+ resume?: boolean;
20
+ }): Promise<void>;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ /**
3
+ * sf heartbeat <thesisId> — View/update per-thesis heartbeat config & costs.
4
+ *
5
+ * Examples:
6
+ * sf heartbeat abc123 — show config + costs
7
+ * sf heartbeat abc123 --news-interval 30 — set news scan to 30 min
8
+ * sf heartbeat abc123 --model heavy — use heavy model for eval
9
+ * sf heartbeat abc123 --budget 5 — $5/month cap
10
+ * sf heartbeat abc123 --pause — pause heartbeat
11
+ * sf heartbeat abc123 --resume — resume heartbeat
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.heartbeatCommand = heartbeatCommand;
15
+ const utils_js_1 = require("../utils.js");
16
+ async function heartbeatCommand(sfClient, thesisId, opts) {
17
+ // Check if any update flags were passed
18
+ const hasUpdates = opts.newsInterval || opts.xInterval || opts.model || opts.budget !== undefined || opts.pause || opts.resume;
19
+ if (hasUpdates) {
20
+ const updates = {};
21
+ if (opts.newsInterval)
22
+ updates.newsIntervalMin = parseInt(opts.newsInterval, 10);
23
+ if (opts.xInterval)
24
+ updates.xIntervalMin = parseInt(opts.xInterval, 10);
25
+ if (opts.model)
26
+ updates.evalModelTier = opts.model;
27
+ if (opts.budget !== undefined)
28
+ updates.monthlyBudgetUsd = parseFloat(opts.budget);
29
+ if (opts.pause)
30
+ updates.paused = true;
31
+ if (opts.resume)
32
+ updates.paused = false;
33
+ try {
34
+ const result = await sfClient.updateHeartbeatConfig(thesisId, updates);
35
+ console.log(`\n ${utils_js_1.c.green}✓${utils_js_1.c.reset} Updated heartbeat config`);
36
+ console.log(` ${utils_js_1.c.dim}Updated fields: ${result.updated.join(', ')}${utils_js_1.c.reset}\n`);
37
+ }
38
+ catch (err) {
39
+ console.error(` ${utils_js_1.c.red}✗ ${err.message}${utils_js_1.c.reset}`);
40
+ return;
41
+ }
42
+ }
43
+ // Always show current config
44
+ try {
45
+ const data = await sfClient.getHeartbeatConfig(thesisId);
46
+ const cfg = data.config;
47
+ const costs = data.costs;
48
+ console.log(`\n ${utils_js_1.c.bold}Heartbeat Config${utils_js_1.c.reset} ${utils_js_1.c.dim}(${thesisId.slice(0, 8)})${utils_js_1.c.reset}`);
49
+ console.log();
50
+ // Config
51
+ const statusIcon = cfg.paused ? `${utils_js_1.c.red}⏸ paused${utils_js_1.c.reset}` : `${utils_js_1.c.green}▶ active${utils_js_1.c.reset}`;
52
+ console.log(` Status ${statusIcon}`);
53
+ console.log(` News interval ${utils_js_1.c.cyan}${cfg.newsIntervalMin}${utils_js_1.c.reset} min ${utils_js_1.c.dim}(${cfg.newsIntervalMin === data.defaults.newsIntervalMin ? 'default' : 'custom'})${utils_js_1.c.reset}`);
54
+ console.log(` X interval ${utils_js_1.c.cyan}${cfg.xIntervalMin}${utils_js_1.c.reset} min ${utils_js_1.c.dim}(${cfg.xIntervalMin === data.defaults.xIntervalMin ? 'default' : 'custom'})${utils_js_1.c.reset}`);
55
+ console.log(` Eval model ${utils_js_1.c.cyan}${cfg.evalModelTier}${utils_js_1.c.reset} ${utils_js_1.c.dim}(${cfg.evalModelTier === data.defaults.evalModelTier ? 'default' : 'custom'})${utils_js_1.c.reset}`);
56
+ console.log(` Monthly budget ${cfg.monthlyBudgetUsd > 0 ? `${utils_js_1.c.cyan}$${cfg.monthlyBudgetUsd}${utils_js_1.c.reset}` : `${utils_js_1.c.dim}unlimited${utils_js_1.c.reset}`}`);
57
+ console.log();
58
+ // Costs
59
+ console.log(` ${utils_js_1.c.bold}This Month${utils_js_1.c.reset}`);
60
+ console.log(` Total cost ${utils_js_1.c.cyan}$${costs.monthlyTotal.toFixed(4)}${utils_js_1.c.reset}`);
61
+ console.log(` LLM calls ${costs.llmCalls}`);
62
+ console.log(` Search calls ${costs.searchCalls}`);
63
+ console.log(` Tokens ${utils_js_1.c.dim}${costs.inputTokens.toLocaleString()} in / ${costs.outputTokens.toLocaleString()} out${utils_js_1.c.reset}`);
64
+ if (costs.budgetRemaining !== null) {
65
+ const pct = cfg.monthlyBudgetUsd > 0 ? Math.round((costs.monthlyTotal / cfg.monthlyBudgetUsd) * 100) : 0;
66
+ console.log(` Budget used ${pct}% ${utils_js_1.c.dim}($${costs.budgetRemaining.toFixed(2)} remaining)${utils_js_1.c.reset}`);
67
+ }
68
+ console.log();
69
+ }
70
+ catch (err) {
71
+ console.error(` ${utils_js_1.c.red}✗ ${err.message}${utils_js_1.c.reset}`);
72
+ }
73
+ }
package/dist/index.js CHANGED
@@ -59,6 +59,7 @@ const augment_js_1 = require("./commands/augment.js");
59
59
  const telegram_js_1 = require("./commands/telegram.js");
60
60
  const delta_js_1 = require("./commands/delta.js");
61
61
  const login_js_1 = require("./commands/login.js");
62
+ const heartbeat_js_1 = require("./commands/heartbeat.js");
62
63
  const query_js_1 = require("./commands/query.js");
63
64
  const markets_js_1 = require("./commands/markets.js");
64
65
  const x_js_1 = require("./commands/x.js");
@@ -88,6 +89,7 @@ const GROUPED_HELP = `
88
89
  \x1b[36mevaluate\x1b[39m <id> Trigger deep evaluation
89
90
  \x1b[36maugment\x1b[39m <id> Evolve causal tree with new nodes
90
91
  \x1b[36mpublish\x1b[39m / \x1b[36munpublish\x1b[39m <id> Manage public visibility
92
+ \x1b[36mheartbeat\x1b[39m <id> View/configure heartbeat settings & costs
91
93
 
92
94
  \x1b[1mSearch\x1b[22m
93
95
  \x1b[36mquery\x1b[39m "question" LLM-enhanced market knowledge search \x1b[2m(no auth)\x1b[22m
@@ -342,6 +344,30 @@ program
342
344
  const g = cmd.optsWithGlobals();
343
345
  await run(() => (0, evaluate_js_1.evaluateCommand)(id, { apiKey: g.apiKey, apiUrl: g.apiUrl }));
344
346
  });
347
+ // ── sf heartbeat <id> ─────────────────────────────────────────────────────────
348
+ program
349
+ .command('heartbeat <id>')
350
+ .description('View/configure per-thesis heartbeat settings and costs')
351
+ .option('--news-interval <min>', 'News scan interval in minutes (15-1440)')
352
+ .option('--x-interval <min>', 'X/social scan interval in minutes (60-1440)')
353
+ .option('--model <tier>', 'Eval model tier: cheap, medium, heavy')
354
+ .option('--budget <usd>', 'Monthly budget cap in USD (0 = unlimited)')
355
+ .option('--pause', 'Pause heartbeat')
356
+ .option('--resume', 'Resume heartbeat')
357
+ .action(async (id, opts, cmd) => {
358
+ const g = cmd.optsWithGlobals();
359
+ await run(() => {
360
+ const client = new client_js_1.SFClient(g.apiKey, g.apiUrl);
361
+ return (0, heartbeat_js_1.heartbeatCommand)(client, id, {
362
+ newsInterval: opts.newsInterval,
363
+ xInterval: opts.xInterval,
364
+ model: opts.model,
365
+ budget: opts.budget,
366
+ pause: opts.pause,
367
+ resume: opts.resume,
368
+ });
369
+ });
370
+ });
345
371
  // ── sf scan [query] ───────────────────────────────────────────────────────────
346
372
  program
347
373
  .command('scan [query]')
@@ -362,6 +362,36 @@ async function buildTools(sfClient, thesisId, latestContext) {
362
362
  const data = await sfClient.getXAccount(p.username, { hours: p.hours });
363
363
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
364
364
  },
365
+ }, {
366
+ name: 'heartbeat_config', label: 'Heartbeat Config',
367
+ description: 'View or update heartbeat settings: scan intervals, model tier, budget cap, pause/resume. Shows cost breakdown.',
368
+ parameters: Type.Object({
369
+ thesisId: Type.String({ description: 'Thesis ID' }),
370
+ newsIntervalMin: Type.Optional(Type.Number({ description: 'News scan interval (15-1440 min)' })),
371
+ xIntervalMin: Type.Optional(Type.Number({ description: 'X scan interval (60-1440 min)' })),
372
+ evalModelTier: Type.Optional(Type.String({ description: 'cheap, medium, or heavy' })),
373
+ monthlyBudgetUsd: Type.Optional(Type.Number({ description: 'Monthly budget (0 = unlimited)' })),
374
+ paused: Type.Optional(Type.Boolean({ description: 'Pause or resume heartbeat' })),
375
+ }),
376
+ execute: async (_id, p) => {
377
+ const hasUp = p.newsIntervalMin || p.xIntervalMin || p.evalModelTier || p.monthlyBudgetUsd !== undefined || p.paused !== undefined;
378
+ if (hasUp) {
379
+ const u = {};
380
+ if (p.newsIntervalMin)
381
+ u.newsIntervalMin = p.newsIntervalMin;
382
+ if (p.xIntervalMin)
383
+ u.xIntervalMin = p.xIntervalMin;
384
+ if (p.evalModelTier)
385
+ u.evalModelTier = p.evalModelTier;
386
+ if (p.monthlyBudgetUsd !== undefined)
387
+ u.monthlyBudgetUsd = p.monthlyBudgetUsd;
388
+ if (p.paused !== undefined)
389
+ u.paused = p.paused;
390
+ await sfClient.updateHeartbeatConfig(p.thesisId, u);
391
+ }
392
+ const data = await sfClient.getHeartbeatConfig(p.thesisId);
393
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
394
+ },
365
395
  });
366
396
  // Trading tools (only if enabled)
367
397
  if (config.tradingEnabled) {
@@ -470,6 +500,7 @@ Price: depth >= 500 = consensus, < 100 = unreliable, spread > 5 = noisy.
470
500
  - Prices in cents (¢). P&L in dollars ($). Don't re-convert tool output units.
471
501
  - Call tools for fresh data. Never guess prices or P&L from this prompt.
472
502
  - Use search_x for X/Twitter sentiment. Use x_volume for discussion spikes. Use x_account to track key people.
503
+ - Use heartbeat_config to view/adjust scan intervals, model tier, budget cap, or check cost breakdown.
473
504
  - You don't know user's positions. Call get_positions before discussing trades.
474
505
  - If user mentions news, inject_signal immediately. Don't ask "should I?"
475
506
  - If user says "evaluate" or "run it", trigger immediately.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfunctions/cli",
3
- "version": "1.7.13",
3
+ "version": "1.7.16",
4
4
  "description": "Prediction market intelligence CLI. Causal thesis model, 24/7 Kalshi/Polymarket scan, live orderbook, edge detection. Interactive agent mode with tool calling.",
5
5
  "bin": {
6
6
  "sf": "./dist/index.js"