@spfunctions/cli 1.7.14 → 1.7.17

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.
@@ -26,6 +26,7 @@ const kalshi_js_1 = require("../kalshi.js");
26
26
  const polymarket_js_1 = require("../polymarket.js");
27
27
  const topics_js_1 = require("../topics.js");
28
28
  const config_js_1 = require("../config.js");
29
+ const loader_js_1 = require("../skills/loader.js");
29
30
  // ─── Session persistence ─────────────────────────────────────────────────────
30
31
  function getSessionDir() {
31
32
  return path_1.default.join(os_1.default.homedir(), '.sf', 'sessions');
@@ -219,21 +220,37 @@ function createFooterBar(piTui) {
219
220
  exchangeOpen = null;
220
221
  cachedWidth;
221
222
  cachedLines;
223
+ isExplorer = false;
222
224
  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`;
225
+ if (ctx._explorerMode) {
226
+ this.isExplorer = true;
227
+ this.thesisId = 'Explorer';
228
+ this.confidence = 0;
229
+ this.confidenceDelta = 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) - Math.abs(a.edge))[0];
234
+ this.topEdge = `${(top.title || '').slice(0, 20)} +${Math.round(top.edge)}¢`;
235
+ }
236
+ }
237
+ else {
238
+ this.isExplorer = false;
239
+ this.thesisId = (ctx.thesisId || '').slice(0, 8);
240
+ this.confidence = typeof ctx.confidence === 'number'
241
+ ? Math.round(ctx.confidence * 100)
242
+ : (typeof ctx.confidence === 'string' ? Math.round(parseFloat(ctx.confidence) * 100) : 0);
243
+ this.confidenceDelta = ctx.lastEvaluation?.confidenceDelta
244
+ ? Math.round(ctx.lastEvaluation.confidenceDelta * 100)
245
+ : 0;
246
+ this.edgeCount = (ctx.edges || []).length;
247
+ const edges = ctx.edges || [];
248
+ if (edges.length > 0) {
249
+ const top = [...edges].sort((a, b) => Math.abs(b.edge || b.edgeSize || 0) - Math.abs(a.edge || a.edgeSize || 0))[0];
250
+ const name = (top.market || top.marketTitle || top.marketId || '').slice(0, 20);
251
+ const edge = top.edge || top.edgeSize || 0;
252
+ this.topEdge = `${name} ${edge > 0 ? '+' : ''}${Math.round(edge)}\u00A2`;
253
+ }
237
254
  }
238
255
  if (positions && positions.length > 0) {
239
256
  this.positionCount = positions.length;
@@ -260,23 +277,39 @@ function createFooterBar(piTui) {
260
277
  if (this.cachedLines && this.cachedWidth === width)
261
278
  return this.cachedLines;
262
279
  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) : '';
280
+ // Line 1: thesis info (or explorer mode)
278
281
  const sep = C.zinc600(' \u2502 ');
279
- const line1Parts = [id, conf, pnl, edges, top].filter(Boolean);
282
+ let line1Parts;
283
+ if (this.isExplorer) {
284
+ const id = C.emerald(bold('Explorer'));
285
+ const edges = C.zinc600(`${this.edgeCount} public edges`);
286
+ const top = this.topEdge ? C.zinc400(this.topEdge) : '';
287
+ let pnl = '';
288
+ if (this.positionCount > 0) {
289
+ const pnlStr = this.pnlDollars >= 0
290
+ ? C.emerald(`+$${this.pnlDollars.toFixed(2)}`)
291
+ : C.red(`-$${Math.abs(this.pnlDollars).toFixed(2)}`);
292
+ pnl = C.zinc600(`${this.positionCount} pos `) + pnlStr;
293
+ }
294
+ line1Parts = [id, pnl, edges, top].filter(Boolean);
295
+ }
296
+ else {
297
+ const id = C.emerald(this.thesisId);
298
+ const arrow = this.confidenceDelta > 0 ? '\u25B2' : this.confidenceDelta < 0 ? '\u25BC' : '\u2500';
299
+ const arrowColor = this.confidenceDelta > 0 ? C.emerald : this.confidenceDelta < 0 ? C.red : C.zinc600;
300
+ const deltaStr = this.confidenceDelta !== 0 ? ` (${this.confidenceDelta > 0 ? '+' : ''}${this.confidenceDelta})` : '';
301
+ const conf = arrowColor(`${arrow} ${this.confidence}%${deltaStr}`);
302
+ let pnl = '';
303
+ if (this.positionCount > 0) {
304
+ const pnlStr = this.pnlDollars >= 0
305
+ ? C.emerald(`+$${this.pnlDollars.toFixed(2)}`)
306
+ : C.red(`-$${Math.abs(this.pnlDollars).toFixed(2)}`);
307
+ pnl = C.zinc600(`${this.positionCount} pos `) + pnlStr;
308
+ }
309
+ const edges = C.zinc600(`${this.edgeCount} edges`);
310
+ const top = this.topEdge ? C.zinc400(this.topEdge) : '';
311
+ line1Parts = [id, conf, pnl, edges, top].filter(Boolean);
312
+ }
280
313
  let line1 = C.bgZinc800(' ' + truncateToWidth(line1Parts.join(sep), width - 2, '') + ' ');
281
314
  const l1vw = visibleWidth(line1);
282
315
  if (l1vw < width)
@@ -393,14 +426,18 @@ function renderPositions(positions) {
393
426
  return lines.join('\n');
394
427
  }
395
428
  // ─── Thesis selector (arrow keys + enter, like Claude Code) ─────────────────
396
- async function selectThesis(theses) {
429
+ async function selectThesis(theses, includeExplorer = false) {
397
430
  return new Promise((resolve) => {
398
431
  let selected = 0;
399
- const items = theses.map((t) => {
432
+ const items = [];
433
+ if (includeExplorer) {
434
+ items.push({ id: '_explorer', conf: -1, title: 'Explorer mode — no thesis, full market access' });
435
+ }
436
+ for (const t of theses) {
400
437
  const conf = typeof t.confidence === 'number' ? Math.round(t.confidence * 100) : 0;
401
438
  const title = (t.rawThesis || t.thesis || t.title || '').slice(0, 55);
402
- return { id: t.id, conf, title };
403
- });
439
+ items.push({ id: t.id, conf, title });
440
+ }
404
441
  const write = process.stdout.write.bind(process.stdout);
405
442
  // Use alternate screen buffer for clean rendering (like Claude Code)
406
443
  write('\x1b[?1049h'); // enter alternate screen
@@ -412,10 +449,16 @@ async function selectThesis(theses) {
412
449
  const item = items[i];
413
450
  const sel = i === selected;
414
451
  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`);
452
+ if (item.id === '_explorer') {
453
+ const title = sel ? `\x1b[38;2;16;185;129m${item.title}\x1b[39m` : `\x1b[38;2;80;80;88m${item.title}\x1b[39m`;
454
+ write(`${cursor}${title}\n`);
455
+ }
456
+ else {
457
+ 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`;
458
+ const conf = `\x1b[38;2;55;55;60m${item.conf}%\x1b[39m`;
459
+ const title = sel ? `\x1b[38;2;160;160;165m${item.title}\x1b[39m` : `\x1b[38;2;80;80;88m${item.title}\x1b[39m`;
460
+ write(`${cursor}${id} ${conf} ${title}\n`);
461
+ }
419
462
  }
420
463
  write(`\n \x1b[38;2;55;55;60m↑↓ navigate · enter select\x1b[39m`);
421
464
  }
@@ -507,36 +550,35 @@ async function agentCommand(thesisId, opts) {
507
550
  }
508
551
  const sfClient = new client_js_1.SFClient();
509
552
  // ── Resolve thesis ID (interactive selection if needed) ─────────────────────
510
- let resolvedThesisId = thesisId;
553
+ let resolvedThesisId = thesisId || null;
554
+ let explorerMode = false;
511
555
  if (!resolvedThesisId) {
512
- const data = await sfClient.listTheses();
513
- const theses = (data.theses || data);
514
- const active = theses.filter((t) => t.status === 'active');
556
+ let active = [];
557
+ try {
558
+ const data = await sfClient.listTheses();
559
+ const theses = (data.theses || data);
560
+ active = theses.filter((t) => t.status === 'active');
561
+ }
562
+ catch {
563
+ // No API key or network error — explorer mode
564
+ active = [];
565
+ }
515
566
  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`);
567
+ // No theses — go straight to explorer mode
568
+ explorerMode = true;
533
569
  }
534
570
  else if (active.length === 1) {
535
571
  resolvedThesisId = active[0].id;
536
572
  }
537
573
  else if (process.stdin.isTTY && !opts?.noTui) {
538
- // Multiple theses — interactive arrow key selector (TUI only)
539
- resolvedThesisId = await selectThesis(active);
574
+ // Multiple theses — interactive selector with explorer option at top
575
+ const selected = await selectThesis(active, true);
576
+ if (selected === '_explorer') {
577
+ explorerMode = true;
578
+ }
579
+ else {
580
+ resolvedThesisId = selected;
581
+ }
540
582
  }
541
583
  else {
542
584
  // Non-interactive (--plain, telegram, piped) — use first active
@@ -544,10 +586,18 @@ async function agentCommand(thesisId, opts) {
544
586
  }
545
587
  }
546
588
  // ── Fetch initial context ──────────────────────────────────────────────────
547
- let latestContext = await sfClient.getContext(resolvedThesisId);
589
+ let latestContext;
590
+ if (explorerMode) {
591
+ const { fetchGlobalContext } = await import('../client.js');
592
+ latestContext = await fetchGlobalContext();
593
+ latestContext._explorerMode = true;
594
+ }
595
+ else {
596
+ latestContext = await sfClient.getContext(resolvedThesisId);
597
+ }
548
598
  // ── Branch: plain-text mode ────────────────────────────────────────────────
549
599
  if (opts?.noTui) {
550
- return runPlainTextAgent({ openrouterKey, sfClient, resolvedThesisId: resolvedThesisId, latestContext, useProxy, llmBaseUrl, sfApiKey, sfApiUrl, opts });
600
+ return runPlainTextAgent({ openrouterKey, sfClient, resolvedThesisId: resolvedThesisId || '_explorer', latestContext, useProxy, llmBaseUrl, sfApiKey, sfApiUrl, opts });
551
601
  }
552
602
  // ── Dynamic imports (all ESM-only packages) ────────────────────────────────
553
603
  const piTui = await import('@mariozechner/pi-tui');
@@ -690,6 +740,12 @@ async function agentCommand(thesisId, opts) {
690
740
  slashCommands.splice(-2, 0, // insert before /clear and /exit
691
741
  { name: 'buy', description: 'TICKER QTY PRICE — quick buy' }, { name: 'sell', description: 'TICKER QTY PRICE — quick sell' }, { name: 'cancel', description: 'ORDER_ID — cancel order' });
692
742
  }
743
+ // Load skills and register as slash commands
744
+ const skills = (0, loader_js_1.loadSkills)();
745
+ for (const skill of skills) {
746
+ const trigger = skill.trigger.replace(/^\//, ''); // remove leading /
747
+ slashCommands.splice(-2, 0, { name: trigger, description: `[skill] ${skill.description.slice(0, 50)}` });
748
+ }
693
749
  const autocompleteProvider = new CombinedAutocompleteProvider(slashCommands, process.cwd());
694
750
  editor.setAutocompleteProvider(autocompleteProvider);
695
751
  // Assemble TUI tree
@@ -1218,16 +1274,34 @@ async function agentCommand(thesisId, opts) {
1218
1274
  {
1219
1275
  name: 'get_orders',
1220
1276
  label: 'Orders',
1221
- description: 'Get current resting orders on Kalshi.',
1277
+ description: 'Get current resting orders on Kalshi. Stale orders (>7 days old AND >10¢ from market) are flagged.',
1222
1278
  parameters: Type.Object({
1223
1279
  status: Type.Optional(Type.String({ description: 'Filter by status: resting, canceled, executed. Default: resting' })),
1224
1280
  }),
1225
1281
  execute: async (_toolCallId, params) => {
1226
- const { getOrders } = await import('../kalshi.js');
1282
+ const { getOrders, getMarketPrice } = await import('../kalshi.js');
1227
1283
  const result = await getOrders({ status: params.status || 'resting', limit: 100 });
1228
1284
  if (!result)
1229
1285
  return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
1230
- return { content: [{ type: 'text', text: JSON.stringify(result.orders, null, 2) }], details: {} };
1286
+ // Enrich orders with staleness detection
1287
+ const enriched = await Promise.all((result.orders || []).map(async (order) => {
1288
+ const daysSinceCreated = order.created_time
1289
+ ? Math.round((Date.now() - new Date(order.created_time).getTime()) / 86400000)
1290
+ : null;
1291
+ let distanceFromMarket = null;
1292
+ let stale = false;
1293
+ try {
1294
+ const price = await getMarketPrice(order.ticker);
1295
+ if (price != null && order.yes_price_dollars) {
1296
+ distanceFromMarket = Math.round(Math.abs(price - parseFloat(order.yes_price_dollars)) * 100);
1297
+ if (daysSinceCreated != null && daysSinceCreated > 7 && distanceFromMarket > 10)
1298
+ stale = true;
1299
+ }
1300
+ }
1301
+ catch { }
1302
+ return { ...order, daysSinceCreated, distanceFromMarket, stale };
1303
+ }));
1304
+ return { content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }], details: {} };
1231
1305
  },
1232
1306
  },
1233
1307
  {
@@ -1307,42 +1381,71 @@ async function agentCommand(thesisId, opts) {
1307
1381
  {
1308
1382
  name: 'inspect_book',
1309
1383
  label: 'Orderbook',
1310
- description: 'Get orderbook depth, spread, and liquidity for a specific market. Works with Kalshi tickers or Polymarket search queries. Returns bid/ask levels, depth, spread, liquidity score.',
1384
+ description: 'Get orderbook depth, spread, and liquidity. Returns a status field per market: "ok", "empty_orderbook", "market_closed", or "api_error". Supports multiple tickers in one call use tickers array for batch position checks.',
1311
1385
  parameters: Type.Object({
1312
- ticker: Type.Optional(Type.String({ description: 'Kalshi market ticker (e.g. KXWTIMAX-26DEC31-T135)' })),
1386
+ ticker: Type.Optional(Type.String({ description: 'Single Kalshi market ticker (e.g. KXWTIMAX-26DEC31-T135)' })),
1387
+ tickers: Type.Optional(Type.Array(Type.String(), { description: 'Multiple Kalshi tickers for batch check (e.g. ["T$135", "T$140", "T$150"])' })),
1313
1388
  polyQuery: Type.Optional(Type.String({ description: 'Search Polymarket by keyword (e.g. "oil price above 100")' })),
1314
1389
  }),
1315
1390
  execute: async (_toolCallId, params) => {
1316
1391
  const results = [];
1317
- if (params.ticker) {
1392
+ // Batch: expand tickers array into individual lookups
1393
+ const tickerList = [];
1394
+ if (params.tickers?.length)
1395
+ tickerList.push(...params.tickers);
1396
+ else if (params.ticker)
1397
+ tickerList.push(params.ticker);
1398
+ for (const tkr of tickerList) {
1318
1399
  try {
1319
- const market = await (0, client_js_1.kalshiFetchMarket)(params.ticker);
1320
- const ob = await (0, kalshi_js_1.getPublicOrderbook)(params.ticker);
1321
- const yesBids = (ob?.yes_dollars || [])
1322
- .map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), size: Math.round(parseFloat(q)) }))
1323
- .filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
1324
- const noAsks = (ob?.no_dollars || [])
1325
- .map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), size: Math.round(parseFloat(q)) }))
1326
- .filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
1327
- const bestBid = yesBids[0]?.price || 0;
1328
- const bestAsk = noAsks.length > 0 ? (100 - noAsks[0].price) : 100;
1329
- const spread = bestAsk - bestBid;
1330
- const top3Depth = yesBids.slice(0, 3).reduce((s, l) => s + l.size, 0) + noAsks.slice(0, 3).reduce((s, l) => s + l.size, 0);
1331
- const liq = spread <= 2 && top3Depth >= 500 ? 'high' : spread <= 5 && top3Depth >= 100 ? 'medium' : 'low';
1332
- results.push({
1333
- venue: 'kalshi', ticker: params.ticker, title: market.title,
1334
- bestBid, bestAsk, spread, liquidityScore: liq,
1335
- bidLevels: yesBids.slice(0, 5), askLevels: noAsks.slice(0, 5).map((l) => ({ price: 100 - l.price, size: l.size })),
1336
- totalBidDepth: yesBids.reduce((s, l) => s + l.size, 0),
1337
- totalAskDepth: noAsks.reduce((s, l) => s + l.size, 0),
1338
- lastPrice: Math.round(parseFloat(market.last_price_dollars || '0') * 100),
1339
- volume24h: parseFloat(market.volume_24h_fp || '0'),
1340
- openInterest: parseFloat(market.open_interest_fp || '0'),
1341
- expiry: market.close_time || null,
1342
- });
1400
+ const market = await (0, client_js_1.kalshiFetchMarket)(tkr);
1401
+ const mStatus = market.status || 'unknown';
1402
+ if (mStatus !== 'open' && mStatus !== 'active') {
1403
+ results.push({
1404
+ venue: 'kalshi', ticker: tkr, title: market.title,
1405
+ status: 'market_closed', reason: `Market status: ${mStatus}. Orderbook unavailable for closed/settled markets.`,
1406
+ lastPrice: Math.round(parseFloat(market.last_price_dollars || '0') * 100),
1407
+ });
1408
+ }
1409
+ else {
1410
+ const ob = await (0, kalshi_js_1.getPublicOrderbook)(tkr);
1411
+ const yesBids = (ob?.yes_dollars || [])
1412
+ .map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), size: Math.round(parseFloat(q)) }))
1413
+ .filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
1414
+ const noAsks = (ob?.no_dollars || [])
1415
+ .map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), size: Math.round(parseFloat(q)) }))
1416
+ .filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
1417
+ if (yesBids.length === 0 && noAsks.length === 0) {
1418
+ results.push({
1419
+ venue: 'kalshi', ticker: tkr, title: market.title,
1420
+ status: 'empty_orderbook', reason: 'Market open but no resting orders. Normal for illiquid/OTM contracts. Use lastPrice as reference.',
1421
+ lastPrice: Math.round(parseFloat(market.last_price_dollars || '0') * 100),
1422
+ volume24h: parseFloat(market.volume_24h_fp || '0'),
1423
+ openInterest: parseFloat(market.open_interest_fp || '0'),
1424
+ expiry: market.close_time || null,
1425
+ });
1426
+ }
1427
+ else {
1428
+ const bestBid = yesBids[0]?.price || 0;
1429
+ const bestAsk = noAsks.length > 0 ? (100 - noAsks[0].price) : (yesBids[0] ? yesBids[0].price + 1 : 100);
1430
+ const spread = bestAsk - bestBid;
1431
+ const top3Depth = yesBids.slice(0, 3).reduce((s, l) => s + l.size, 0) + noAsks.slice(0, 3).reduce((s, l) => s + l.size, 0);
1432
+ const liq = spread <= 2 && top3Depth >= 500 ? 'high' : spread <= 5 && top3Depth >= 100 ? 'medium' : 'low';
1433
+ results.push({
1434
+ venue: 'kalshi', ticker: tkr, title: market.title, status: 'ok',
1435
+ bestBid, bestAsk, spread, liquidityScore: liq,
1436
+ bidLevels: yesBids.slice(0, 5), askLevels: noAsks.slice(0, 5).map((l) => ({ price: 100 - l.price, size: l.size })),
1437
+ totalBidDepth: yesBids.reduce((s, l) => s + l.size, 0),
1438
+ totalAskDepth: noAsks.reduce((s, l) => s + l.size, 0),
1439
+ lastPrice: Math.round(parseFloat(market.last_price_dollars || '0') * 100),
1440
+ volume24h: parseFloat(market.volume_24h_fp || '0'),
1441
+ openInterest: parseFloat(market.open_interest_fp || '0'),
1442
+ expiry: market.close_time || null,
1443
+ });
1444
+ }
1445
+ }
1343
1446
  }
1344
1447
  catch (err) {
1345
- return { content: [{ type: 'text', text: `Failed: ${err.message}` }], details: {} };
1448
+ results.push({ venue: 'kalshi', ticker: tkr, status: 'api_error', reason: `Kalshi API error: ${err.message}` });
1346
1449
  }
1347
1450
  }
1348
1451
  if (params.polyQuery) {
@@ -1399,7 +1502,7 @@ async function agentCommand(thesisId, opts) {
1399
1502
  {
1400
1503
  name: 'create_thesis',
1401
1504
  label: 'Create Thesis',
1402
- description: 'Create a new thesis from a raw thesis statement. Returns the thesis ID, confidence, node count, and edge count.',
1505
+ 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
1506
  parameters: Type.Object({
1404
1507
  rawThesis: Type.String({ description: 'The raw thesis statement to create' }),
1405
1508
  webhookUrl: Type.Optional(Type.String({ description: 'Optional webhook URL for notifications' })),
@@ -1410,8 +1513,21 @@ async function agentCommand(thesisId, opts) {
1410
1513
  const nodeCount = thesis.causalTree?.nodes?.length || 0;
1411
1514
  const edgeCount = (thesis.edges || []).length;
1412
1515
  const confidence = typeof thesis.confidence === 'number' ? Math.round(thesis.confidence * 100) : 0;
1516
+ // ── Auto-transition from explorer to thesis mode ──────────────────
1517
+ if (explorerMode && thesis.id) {
1518
+ explorerMode = false;
1519
+ resolvedThesisId = thesis.id;
1520
+ try {
1521
+ latestContext = await sfClient.getContext(thesis.id);
1522
+ const newPrompt = buildSystemPrompt(latestContext);
1523
+ agent.setSystemPrompt(newPrompt);
1524
+ footerBar.setFromContext(latestContext, initialPositions || undefined);
1525
+ tui.requestRender();
1526
+ }
1527
+ catch { /* context fetch failed, still switch */ }
1528
+ }
1413
1529
  return {
1414
- content: [{ type: 'text', text: `Thesis created.\nID: ${thesis.id}\nConfidence: ${confidence}%\nNodes: ${nodeCount}\nEdges: ${edgeCount}` }],
1530
+ 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
1531
  details: {},
1416
1532
  };
1417
1533
  },
@@ -1450,14 +1566,64 @@ async function agentCommand(thesisId, opts) {
1450
1566
  {
1451
1567
  name: 'get_feed',
1452
1568
  label: 'Get Feed',
1453
- description: 'Get evaluation history / activity feed. Shows recent evaluations, signals, and changes across theses.',
1569
+ description: 'Get evaluation history with topSignal highlighting. The most important signal (largest confidence change or most actionable) is surfaced first so you don\'t have to scan all entries.',
1454
1570
  parameters: Type.Object({
1455
1571
  hours: Type.Optional(Type.Number({ description: 'Hours of history to fetch (default 24)' })),
1456
1572
  }),
1457
1573
  execute: async (_toolCallId, params) => {
1458
1574
  const result = await sfClient.getFeed(params.hours || 24);
1575
+ const items = Array.isArray(result) ? result : (result?.evaluations || result?.items || []);
1576
+ // Find the most important signal: largest |confidenceDelta|, or newest with actual content
1577
+ let topSignal = null;
1578
+ let topScore = 0;
1579
+ for (const item of items) {
1580
+ let score = 0;
1581
+ const delta = Math.abs(item.confidenceDelta || item.confidence_delta || 0);
1582
+ if (delta > 0)
1583
+ score = delta * 100; // confidence changes are most important
1584
+ else if (item.summary?.length > 50)
1585
+ score = 0.1; // has substance but no delta
1586
+ if (score > topScore) {
1587
+ topScore = score;
1588
+ topSignal = item;
1589
+ }
1590
+ }
1591
+ const output = { total: items.length };
1592
+ if (topSignal) {
1593
+ output.topSignal = {
1594
+ summary: topSignal.summary || topSignal.content || '',
1595
+ confidenceDelta: topSignal.confidenceDelta || topSignal.confidence_delta || 0,
1596
+ evaluatedAt: topSignal.evaluatedAt || topSignal.evaluated_at || topSignal.createdAt || '',
1597
+ why: topScore > 1 ? 'Largest confidence movement in this period'
1598
+ : topScore > 0 ? 'Most substantive evaluation (no confidence change)'
1599
+ : 'Most recent evaluation',
1600
+ };
1601
+ }
1602
+ output.items = items;
1459
1603
  return {
1460
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1604
+ content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
1605
+ details: {},
1606
+ };
1607
+ },
1608
+ },
1609
+ {
1610
+ name: 'get_changes',
1611
+ label: 'Get Changes',
1612
+ description: 'Get recent market changes detected server-side. Returns real price moves (>5¢), new contracts, and removed/settled contracts across Kalshi, Polymarket, and traditional markets. Checked every 15 minutes. Use for situational awareness, discovering new opportunities, or checking if anything material happened recently.',
1613
+ parameters: Type.Object({
1614
+ hours: Type.Optional(Type.Number({ description: 'Hours of history (default 1)' })),
1615
+ }),
1616
+ execute: async (_toolCallId, params) => {
1617
+ const hours = params.hours || 1;
1618
+ const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
1619
+ const apiUrl = process.env.SF_API_URL || 'https://simplefunctions.dev';
1620
+ const res = await fetch(`${apiUrl}/api/changes?since=${encodeURIComponent(since)}&limit=100`);
1621
+ if (!res.ok) {
1622
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `API error ${res.status}` }) }], details: {} };
1623
+ }
1624
+ const data = await res.json();
1625
+ return {
1626
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
1461
1627
  details: {},
1462
1628
  };
1463
1629
  },
@@ -1475,7 +1641,13 @@ async function agentCommand(thesisId, opts) {
1475
1641
  }), { description: 'Node probability overrides' }),
1476
1642
  }),
1477
1643
  execute: async (_toolCallId, params) => {
1478
- // Inline what-if simulation
1644
+ // Refresh context before simulation to avoid stale confidence values
1645
+ if (resolvedThesisId) {
1646
+ try {
1647
+ latestContext = await sfClient.getContext(resolvedThesisId);
1648
+ }
1649
+ catch { }
1650
+ }
1479
1651
  const ctx = latestContext;
1480
1652
  const allNodes = [];
1481
1653
  function flatten(nodes) {
@@ -1489,10 +1661,28 @@ async function agentCommand(thesisId, opts) {
1489
1661
  flatten(rawNodes);
1490
1662
  const treeNodes = rawNodes.filter((n) => n.depth === 0 || (n.depth === undefined && !n.id.includes('.')));
1491
1663
  const overrideMap = new Map(params.overrides.map((o) => [o.nodeId, o.newProbability]));
1664
+ // Propagate child node overrides to parent nodes.
1665
+ // If n2.2 is overridden, recalculate n2's effective probability
1666
+ // as the average of its children's (possibly overridden) probabilities.
1667
+ function effectiveProb(node) {
1668
+ // Direct override on this node
1669
+ if (overrideMap.has(node.id))
1670
+ return overrideMap.get(node.id);
1671
+ // If node has children, aggregate from children
1672
+ if (node.children?.length > 0) {
1673
+ const childProbs = node.children.map((c) => effectiveProb(c));
1674
+ const childImps = node.children.map((c) => c.importance || 1);
1675
+ const totalImp = childImps.reduce((s, w) => s + w, 0);
1676
+ if (totalImp > 0) {
1677
+ return childProbs.reduce((s, p, i) => s + p * childImps[i], 0) / totalImp;
1678
+ }
1679
+ return childProbs.reduce((s, p) => s + p, 0) / childProbs.length;
1680
+ }
1681
+ return node.probability ?? 0;
1682
+ }
1492
1683
  const oldConf = treeNodes.reduce((s, n) => s + (n.probability || 0) * (n.importance || 0), 0);
1493
1684
  const newConf = treeNodes.reduce((s, n) => {
1494
- const p = overrideMap.get(n.id) ?? n.probability ?? 0;
1495
- return s + p * (n.importance || 0);
1685
+ return s + effectiveProb(n) * (n.importance || 0);
1496
1686
  }, 0);
1497
1687
  const nodeScales = new Map();
1498
1688
  for (const [nid, np] of overrideMap.entries()) {
@@ -1527,12 +1717,20 @@ async function agentCommand(thesisId, opts) {
1527
1717
  signal: Math.abs(newEdge - oldEdge) < 1 ? 'unchanged' : (oldEdge > 0 && newEdge < 0) || (oldEdge < 0 && newEdge > 0) ? 'REVERSED' : Math.abs(newEdge) < 2 ? 'GONE' : 'reduced',
1528
1718
  };
1529
1719
  }).filter((e) => e.signal !== 'unchanged');
1720
+ // Server confidence = LLM's holistic assessment (includes factors beyond the tree)
1721
+ // Tree confidence = weighted sum of node probabilities (pure math from causal tree)
1722
+ // These measure different things and will often differ.
1723
+ const serverConf = ctx.confidence != null ? Math.round(Number(ctx.confidence) * 100) : null;
1530
1724
  const result = {
1531
1725
  overrides: params.overrides.map((o) => {
1532
1726
  const node = allNodes.find((n) => n.id === o.nodeId);
1533
1727
  return { nodeId: o.nodeId, label: node?.label || o.nodeId, oldProb: node?.probability, newProb: o.newProbability };
1534
1728
  }),
1535
- confidence: { old: Math.round(oldConf * 100), new: Math.round(newConf * 100), delta: Math.round((newConf - oldConf) * 100) },
1729
+ serverConfidence: serverConf,
1730
+ treeConfidence: { old: Math.round(oldConf * 100), new: Math.round(newConf * 100), delta: Math.round((newConf - oldConf) * 100) },
1731
+ note: serverConf != null && Math.abs(serverConf - Math.round(oldConf * 100)) > 5
1732
+ ? `serverConfidence (${serverConf}%) differs from treeConfidence (${Math.round(oldConf * 100)}%) because the LLM evaluation considers factors beyond the causal tree.`
1733
+ : undefined,
1536
1734
  affectedEdges: edges,
1537
1735
  };
1538
1736
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
@@ -1811,7 +2009,58 @@ ${edgesSummary}
1811
2009
 
1812
2010
  ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation.summary.slice(0, 300)}` : ''}`;
1813
2011
  }
1814
- const systemPrompt = buildSystemPrompt(latestContext);
2012
+ function buildExplorerPrompt(ctx) {
2013
+ const config = (0, config_js_1.loadConfig)();
2014
+ const theseCount = ctx.theses?.length || 0;
2015
+ const edgeCount = ctx.edges?.length || 0;
2016
+ const topEdges = (ctx.edges || [])
2017
+ .sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
2018
+ .slice(0, 5)
2019
+ .map((e) => ` ${(e.title || '').slice(0, 40)} | ${e.venue || 'kalshi'} | mkt ${e.price}¢ | edge +${e.edge}`)
2020
+ .join('\n') || ' (no edges)';
2021
+ return `You are a prediction market research assistant with access to live data across Kalshi, Polymarket, X/Twitter, and traditional markets.
2022
+
2023
+ You are in EXPLORER MODE — not bound to any specific thesis. Help the user research, compare, and understand prediction market data across all sources.
2024
+
2025
+ ## What you can do
2026
+ - Search and compare markets across Kalshi and Polymarket (scan_markets)
2027
+ - Answer questions with live market data + LLM synthesis (query)
2028
+ - Check traditional market prices — SPY, VIX, gold, oil, bonds (get_markets)
2029
+ - Browse public theses and their edges (explore_public)
2030
+ - Search X/Twitter for sentiment and breaking news (search_x, x_volume, x_news)
2031
+ - Check orderbook depth and liquidity (inspect_book, get_liquidity)
2032
+ - View user positions across venues (get_positions)
2033
+ - Create a new thesis when the user forms a view (create_thesis)
2034
+
2035
+ ## CRITICAL: Thesis creation transition
2036
+ 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."
2037
+
2038
+ ## Your analytical framework
2039
+ Edge = thesis price - market price. Positive = market underprices.
2040
+ Edge types: "consensus_gap" (real disagreement), "attention_gap" (no real pricing), "timing_gap" (market lags), "risk_premium" (settlement risk).
2041
+ Price reliability: depth >= 500 = consensus. depth < 100 = unreliable. spread > 5¢ = noisy.
2042
+ Always state contract expiry and next catalyst. No catalyst = capital lock risk.
2043
+
2044
+ ## Your behavioral rules
2045
+ - Be concise. Use tools for fresh data. Don't guess prices.
2046
+ - You do NOT know the user's positions at start. Call get_positions before discussing trades.
2047
+ - If user mentions news, offer to create a thesis or inject a signal if one exists.
2048
+ - Don't end with "anything else?"
2049
+ - Use Chinese if user writes Chinese, English if English.
2050
+ - Prices in cents (¢). P&L in dollars ($).
2051
+
2052
+ ## Trading status
2053
+ ${config.tradingEnabled ? 'Trading is ENABLED.' : 'Trading is DISABLED. Tell user: sf setup --enable-trading'}
2054
+
2055
+ ## Current market snapshot
2056
+ Public theses tracked: ${theseCount}
2057
+ Top edges across all public theses:
2058
+ ${topEdges}
2059
+ `;
2060
+ }
2061
+ const systemPrompt = explorerMode
2062
+ ? buildExplorerPrompt(latestContext)
2063
+ : buildSystemPrompt(latestContext);
1815
2064
  // ── Create Agent ───────────────────────────────────────────────────────────
1816
2065
  const agent = new Agent({
1817
2066
  initialState: {
@@ -1830,7 +2079,7 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
1830
2079
  // ── Session restore ────────────────────────────────────────────────────────
1831
2080
  let sessionRestored = false;
1832
2081
  if (!opts?.newSession) {
1833
- const saved = loadSession(resolvedThesisId);
2082
+ const saved = loadSession(resolvedThesisId || '_explorer');
1834
2083
  if (saved?.messages?.length > 0) {
1835
2084
  try {
1836
2085
  // Clean corrupted messages: empty content, missing role, broken alternation
@@ -1871,7 +2120,7 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
1871
2120
  try {
1872
2121
  const msgs = agent.state.messages;
1873
2122
  if (msgs.length > 0) {
1874
- saveSession(resolvedThesisId, currentModelName, msgs);
2123
+ saveSession(resolvedThesisId || '_explorer', currentModelName, msgs);
1875
2124
  }
1876
2125
  }
1877
2126
  catch { /* best-effort save */ }
@@ -2008,6 +2257,10 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
2008
2257
  C.emerald('/sell ') + C.zinc400('TICKER QTY PRICE \u2014 quick sell') + '\n' +
2009
2258
  C.emerald('/cancel ') + C.zinc400('ORDER_ID \u2014 cancel order') + '\n' +
2010
2259
  C.zinc600('\u2500'.repeat(30)) + '\n') : '') +
2260
+ (skills.length > 0 ? (C.zinc600('\u2500'.repeat(30)) + '\n' +
2261
+ C.zinc200(bold('Skills')) + '\n' +
2262
+ skills.map(s => C.emerald(`/${s.name.padEnd(10)}`) + C.zinc400(s.description.slice(0, 45))).join('\n') + '\n' +
2263
+ C.zinc600('\u2500'.repeat(30)) + '\n') : '') +
2011
2264
  C.emerald('/clear ') + C.zinc400('Clear screen (keeps history)') + '\n' +
2012
2265
  C.emerald('/exit ') + C.zinc400('Exit (auto-saves)'));
2013
2266
  addSpacer();
@@ -2015,29 +2268,58 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
2015
2268
  }
2016
2269
  case '/tree': {
2017
2270
  addSpacer();
2018
- // Refresh context first
2019
- try {
2020
- latestContext = await sfClient.getContext(resolvedThesisId);
2021
- addSystemText(C.zinc200(bold('Causal Tree')) + '\n' + renderCausalTree(latestContext, piTui));
2271
+ if (explorerMode) {
2272
+ addSystemText(C.zinc400('No thesis selected. Use /switch <id> to pick one, or ask me to create one.'));
2022
2273
  }
2023
- catch (err) {
2024
- addSystemText(C.red(`Error: ${err.message}`));
2274
+ else {
2275
+ try {
2276
+ latestContext = await sfClient.getContext(resolvedThesisId);
2277
+ addSystemText(C.zinc200(bold('Causal Tree')) + '\n' + renderCausalTree(latestContext, piTui));
2278
+ }
2279
+ catch (err) {
2280
+ addSystemText(C.red(`Error: ${err.message}`));
2281
+ }
2025
2282
  }
2026
2283
  addSpacer();
2027
2284
  return true;
2028
2285
  }
2029
2286
  case '/edges': {
2030
2287
  addSpacer();
2031
- try {
2032
- latestContext = await sfClient.getContext(resolvedThesisId);
2033
- // Attach cached positions for display
2034
- if (cachedPositions) {
2035
- latestContext._positions = cachedPositions;
2288
+ if (explorerMode) {
2289
+ // Show global public edges
2290
+ try {
2291
+ const { fetchGlobalContext } = await import('../client.js');
2292
+ const global = await fetchGlobalContext();
2293
+ const edges = (global.edges || []).sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge)).slice(0, 10);
2294
+ if (edges.length === 0) {
2295
+ addSystemText(C.zinc400('No public edges available.'));
2296
+ }
2297
+ else {
2298
+ const lines = edges.map((e) => {
2299
+ const name = (e.title || '').slice(0, 35).padEnd(35);
2300
+ const venue = (e.venue || 'kalshi').padEnd(5);
2301
+ const mkt = String(Math.round(e.price || 0)).padStart(3) + '¢';
2302
+ const edge = '+' + Math.round(e.edge || 0);
2303
+ return ` ${C.zinc400(name)} ${C.zinc600(venue)} ${C.zinc400(mkt)} ${C.emerald(edge.padStart(4))}`;
2304
+ }).join('\n');
2305
+ addSystemText(C.zinc200(bold('Public Edges')) + '\n' + lines);
2306
+ }
2307
+ }
2308
+ catch (err) {
2309
+ addSystemText(C.red(`Error: ${err.message}`));
2036
2310
  }
2037
- addSystemText(C.zinc200(bold('Edges')) + '\n' + renderEdges(latestContext, piTui));
2038
2311
  }
2039
- catch (err) {
2040
- addSystemText(C.red(`Error: ${err.message}`));
2312
+ else {
2313
+ try {
2314
+ latestContext = await sfClient.getContext(resolvedThesisId);
2315
+ if (cachedPositions) {
2316
+ latestContext._positions = cachedPositions;
2317
+ }
2318
+ addSystemText(C.zinc200(bold('Edges')) + '\n' + renderEdges(latestContext, piTui));
2319
+ }
2320
+ catch (err) {
2321
+ addSystemText(C.red(`Error: ${err.message}`));
2322
+ }
2041
2323
  }
2042
2324
  addSpacer();
2043
2325
  return true;
@@ -2068,6 +2350,11 @@ ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation
2068
2350
  }
2069
2351
  case '/eval': {
2070
2352
  addSpacer();
2353
+ if (explorerMode) {
2354
+ addSystemText(C.zinc400('No thesis selected. Use /switch <id> to pick one, or ask me to create one.'));
2355
+ addSpacer();
2356
+ return true;
2357
+ }
2071
2358
  addSystemText(C.zinc600('Triggering evaluation...'));
2072
2359
  tui.requestRender();
2073
2360
  try {
@@ -2456,8 +2743,29 @@ Output a structured summary. Be concise but preserve every important detail —
2456
2743
  cleanup();
2457
2744
  return true;
2458
2745
  }
2459
- default:
2746
+ default: {
2747
+ // Check if it's a skill trigger
2748
+ const skill = skills.find(s => s.trigger === command);
2749
+ if (skill) {
2750
+ addSpacer();
2751
+ addSystemText(C.zinc200(`Running skill: ${bold(skill.name)}`) + C.zinc600(` \u2014 ${skill.description.slice(0, 60)}`));
2752
+ addSpacer();
2753
+ tui.requestRender();
2754
+ // Inject the skill prompt → agent executes using existing tools
2755
+ isProcessing = true;
2756
+ try {
2757
+ await agent.prompt(skill.prompt);
2758
+ }
2759
+ catch (err) {
2760
+ addSystemText(C.red(`Skill error: ${err.message}`));
2761
+ }
2762
+ finally {
2763
+ isProcessing = false;
2764
+ }
2765
+ return true;
2766
+ }
2460
2767
  return false;
2768
+ }
2461
2769
  }
2462
2770
  }
2463
2771
  // ── Editor submit handler ──────────────────────────────────────────────────
@@ -2533,6 +2841,34 @@ Output a structured summary. Be concise but preserve every important detail —
2533
2841
  // ── Welcome dashboard builder ────────────────────────────────────────────
2534
2842
  function buildWelcomeDashboard(ctx, positions) {
2535
2843
  const lines = [];
2844
+ // ── Explorer mode welcome ──────────────────────────────────────────────
2845
+ if (ctx._explorerMode) {
2846
+ const edgeCount = ctx.edges?.length || 0;
2847
+ const theseCount = ctx.theses?.length || 0;
2848
+ lines.push(C.zinc600('\u2500'.repeat(55)));
2849
+ lines.push(' ' + C.emerald(bold('Explorer mode')) + C.zinc600(' — full market access, no thesis'));
2850
+ lines.push(' ' + C.zinc600(`${theseCount} public theses \u2502 ${edgeCount} edges tracked`));
2851
+ lines.push(C.zinc600('\u2500'.repeat(55)));
2852
+ // Show top public edges
2853
+ const edges = ctx.edges || [];
2854
+ if (edges.length > 0) {
2855
+ const sorted = [...edges].sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge)).slice(0, 5);
2856
+ lines.push(' ' + C.zinc400(bold('TOP PUBLIC EDGES')) + C.zinc600(' mkt edge'));
2857
+ for (const e of sorted) {
2858
+ const name = (e.title || '').slice(0, 30).padEnd(30);
2859
+ const mkt = String(Math.round(e.price || 0)).padStart(3) + '\u00A2';
2860
+ const edge = e.edge || 0;
2861
+ const edgeStr = '+' + Math.round(edge);
2862
+ const edgeColor = Math.abs(edge) >= 15 ? C.emerald : Math.abs(edge) >= 8 ? C.amber : C.zinc400;
2863
+ lines.push(` ${C.zinc400(name)} ${C.zinc400(mkt)} ${edgeColor(edgeStr.padStart(4))}`);
2864
+ }
2865
+ }
2866
+ lines.push(C.zinc600('\u2500'.repeat(55)));
2867
+ lines.push(' ' + C.zinc600('Ask anything, or describe a view to create a thesis.'));
2868
+ lines.push(C.zinc600('\u2500'.repeat(55)));
2869
+ return lines.join('\n');
2870
+ }
2871
+ // ── Thesis mode welcome (existing) ────────────────────────────────────
2536
2872
  const thesisText = ctx.thesis || ctx.rawThesis || 'N/A';
2537
2873
  const truncated = thesisText.length > 100 ? thesisText.slice(0, 100) + '...' : thesisText;
2538
2874
  const conf = typeof ctx.confidence === 'number'
@@ -2630,25 +2966,26 @@ Output a structured summary. Be concise but preserve every important detail —
2630
2966
  }
2631
2967
  // absDelta === 0: truly nothing changed, stay silent
2632
2968
  }
2633
- // ── Start heartbeat polling ───────────────────────────────────────────────
2634
- heartbeatPollTimer = setInterval(async () => {
2635
- try {
2636
- const delta = await sfClient.getChanges(resolvedThesisId, lastPollTimestamp);
2637
- lastPollTimestamp = new Date().toISOString();
2638
- if (!delta.changed)
2639
- return;
2640
- if (isProcessing || pendingPrompt) {
2641
- // Agent is busy — queue for delivery after agent_end
2642
- pendingHeartbeatDelta = delta;
2969
+ // ── Start heartbeat polling (thesis mode only) ──────────────────────────
2970
+ if (!explorerMode)
2971
+ heartbeatPollTimer = setInterval(async () => {
2972
+ try {
2973
+ const delta = await sfClient.getChanges(resolvedThesisId, lastPollTimestamp);
2974
+ lastPollTimestamp = new Date().toISOString();
2975
+ if (!delta.changed)
2976
+ return;
2977
+ if (isProcessing || pendingPrompt) {
2978
+ // Agent is busy — queue for delivery after agent_end
2979
+ pendingHeartbeatDelta = delta;
2980
+ }
2981
+ else {
2982
+ handleHeartbeatDelta(delta);
2983
+ }
2643
2984
  }
2644
- else {
2645
- handleHeartbeatDelta(delta);
2985
+ catch {
2986
+ // Silent — don't spam errors from background polling
2646
2987
  }
2647
- }
2648
- catch {
2649
- // Silent — don't spam errors from background polling
2650
- }
2651
- }, 60_000); // every 60 seconds
2988
+ }, 60_000); // every 60 seconds
2652
2989
  // ── Start TUI ──────────────────────────────────────────────────────────────
2653
2990
  tui.start();
2654
2991
  }
@@ -3008,42 +3345,71 @@ async function runPlainTextAgent(params) {
3008
3345
  {
3009
3346
  name: 'inspect_book',
3010
3347
  label: 'Orderbook',
3011
- description: 'Get orderbook depth, spread, and liquidity for a specific market. Works with Kalshi tickers or Polymarket search queries. Returns bid/ask levels, depth, spread, liquidity score.',
3348
+ description: 'Get orderbook depth, spread, and liquidity. Returns a status field per market: "ok", "empty_orderbook", "market_closed", or "api_error". Supports multiple tickers in one call use tickers array for batch position checks.',
3012
3349
  parameters: Type.Object({
3013
- ticker: Type.Optional(Type.String({ description: 'Kalshi market ticker (e.g. KXWTIMAX-26DEC31-T135)' })),
3350
+ ticker: Type.Optional(Type.String({ description: 'Single Kalshi market ticker (e.g. KXWTIMAX-26DEC31-T135)' })),
3351
+ tickers: Type.Optional(Type.Array(Type.String(), { description: 'Multiple Kalshi tickers for batch check (e.g. ["T$135", "T$140", "T$150"])' })),
3014
3352
  polyQuery: Type.Optional(Type.String({ description: 'Search Polymarket by keyword (e.g. "oil price above 100")' })),
3015
3353
  }),
3016
3354
  execute: async (_toolCallId, params) => {
3017
3355
  const results = [];
3018
- if (params.ticker) {
3356
+ // Batch: expand tickers array into individual lookups
3357
+ const tickerList = [];
3358
+ if (params.tickers?.length)
3359
+ tickerList.push(...params.tickers);
3360
+ else if (params.ticker)
3361
+ tickerList.push(params.ticker);
3362
+ for (const tkr of tickerList) {
3019
3363
  try {
3020
- const market = await (0, client_js_1.kalshiFetchMarket)(params.ticker);
3021
- const ob = await (0, kalshi_js_1.getPublicOrderbook)(params.ticker);
3022
- const yesBids = (ob?.yes_dollars || [])
3023
- .map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), size: Math.round(parseFloat(q)) }))
3024
- .filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
3025
- const noAsks = (ob?.no_dollars || [])
3026
- .map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), size: Math.round(parseFloat(q)) }))
3027
- .filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
3028
- const bestBid = yesBids[0]?.price || 0;
3029
- const bestAsk = noAsks.length > 0 ? (100 - noAsks[0].price) : 100;
3030
- const spread = bestAsk - bestBid;
3031
- const top3Depth = yesBids.slice(0, 3).reduce((s, l) => s + l.size, 0) + noAsks.slice(0, 3).reduce((s, l) => s + l.size, 0);
3032
- const liq = spread <= 2 && top3Depth >= 500 ? 'high' : spread <= 5 && top3Depth >= 100 ? 'medium' : 'low';
3033
- results.push({
3034
- venue: 'kalshi', ticker: params.ticker, title: market.title,
3035
- bestBid, bestAsk, spread, liquidityScore: liq,
3036
- bidLevels: yesBids.slice(0, 5), askLevels: noAsks.slice(0, 5).map((l) => ({ price: 100 - l.price, size: l.size })),
3037
- totalBidDepth: yesBids.reduce((s, l) => s + l.size, 0),
3038
- totalAskDepth: noAsks.reduce((s, l) => s + l.size, 0),
3039
- lastPrice: Math.round(parseFloat(market.last_price_dollars || '0') * 100),
3040
- volume24h: parseFloat(market.volume_24h_fp || '0'),
3041
- openInterest: parseFloat(market.open_interest_fp || '0'),
3042
- expiry: market.close_time || null,
3043
- });
3364
+ const market = await (0, client_js_1.kalshiFetchMarket)(tkr);
3365
+ const mStatus = market.status || 'unknown';
3366
+ if (mStatus !== 'open' && mStatus !== 'active') {
3367
+ results.push({
3368
+ venue: 'kalshi', ticker: tkr, title: market.title,
3369
+ status: 'market_closed', reason: `Market status: ${mStatus}. Orderbook unavailable for closed/settled markets.`,
3370
+ lastPrice: Math.round(parseFloat(market.last_price_dollars || '0') * 100),
3371
+ });
3372
+ }
3373
+ else {
3374
+ const ob = await (0, kalshi_js_1.getPublicOrderbook)(tkr);
3375
+ const yesBids = (ob?.yes_dollars || [])
3376
+ .map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), size: Math.round(parseFloat(q)) }))
3377
+ .filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
3378
+ const noAsks = (ob?.no_dollars || [])
3379
+ .map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), size: Math.round(parseFloat(q)) }))
3380
+ .filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
3381
+ if (yesBids.length === 0 && noAsks.length === 0) {
3382
+ results.push({
3383
+ venue: 'kalshi', ticker: tkr, title: market.title,
3384
+ status: 'empty_orderbook', reason: 'Market open but no resting orders. Normal for illiquid/OTM contracts. Use lastPrice as reference.',
3385
+ lastPrice: Math.round(parseFloat(market.last_price_dollars || '0') * 100),
3386
+ volume24h: parseFloat(market.volume_24h_fp || '0'),
3387
+ openInterest: parseFloat(market.open_interest_fp || '0'),
3388
+ expiry: market.close_time || null,
3389
+ });
3390
+ }
3391
+ else {
3392
+ const bestBid = yesBids[0]?.price || 0;
3393
+ const bestAsk = noAsks.length > 0 ? (100 - noAsks[0].price) : (yesBids[0] ? yesBids[0].price + 1 : 100);
3394
+ const spread = bestAsk - bestBid;
3395
+ const top3Depth = yesBids.slice(0, 3).reduce((s, l) => s + l.size, 0) + noAsks.slice(0, 3).reduce((s, l) => s + l.size, 0);
3396
+ const liq = spread <= 2 && top3Depth >= 500 ? 'high' : spread <= 5 && top3Depth >= 100 ? 'medium' : 'low';
3397
+ results.push({
3398
+ venue: 'kalshi', ticker: tkr, title: market.title, status: 'ok',
3399
+ bestBid, bestAsk, spread, liquidityScore: liq,
3400
+ bidLevels: yesBids.slice(0, 5), askLevels: noAsks.slice(0, 5).map((l) => ({ price: 100 - l.price, size: l.size })),
3401
+ totalBidDepth: yesBids.reduce((s, l) => s + l.size, 0),
3402
+ totalAskDepth: noAsks.reduce((s, l) => s + l.size, 0),
3403
+ lastPrice: Math.round(parseFloat(market.last_price_dollars || '0') * 100),
3404
+ volume24h: parseFloat(market.volume_24h_fp || '0'),
3405
+ openInterest: parseFloat(market.open_interest_fp || '0'),
3406
+ expiry: market.close_time || null,
3407
+ });
3408
+ }
3409
+ }
3044
3410
  }
3045
3411
  catch (err) {
3046
- return { content: [{ type: 'text', text: `Failed: ${err.message}` }], details: {} };
3412
+ results.push({ venue: 'kalshi', ticker: tkr, status: 'api_error', reason: `Kalshi API error: ${err.message}` });
3047
3413
  }
3048
3414
  }
3049
3415
  if (params.polyQuery) {
@@ -3151,18 +3517,61 @@ async function runPlainTextAgent(params) {
3151
3517
  {
3152
3518
  name: 'get_feed',
3153
3519
  label: 'Get Feed',
3154
- description: 'Get evaluation history / activity feed. Shows recent evaluations, signals, and changes across theses.',
3520
+ description: 'Get evaluation history with topSignal highlighting. The most important signal is surfaced first.',
3155
3521
  parameters: Type.Object({
3156
3522
  hours: Type.Optional(Type.Number({ description: 'Hours of history to fetch (default 24)' })),
3157
3523
  }),
3158
3524
  execute: async (_id, p) => {
3159
3525
  const result = await sfClient.getFeed(p.hours || 24);
3526
+ const items = Array.isArray(result) ? result : (result?.evaluations || result?.items || []);
3527
+ let topSignal = null;
3528
+ let topScore = 0;
3529
+ for (const item of items) {
3530
+ let score = 0;
3531
+ const delta = Math.abs(item.confidenceDelta || item.confidence_delta || 0);
3532
+ if (delta > 0)
3533
+ score = delta * 100;
3534
+ else if (item.summary?.length > 50)
3535
+ score = 0.1;
3536
+ if (score > topScore) {
3537
+ topScore = score;
3538
+ topSignal = item;
3539
+ }
3540
+ }
3541
+ const output = { total: items.length };
3542
+ if (topSignal) {
3543
+ output.topSignal = {
3544
+ summary: topSignal.summary || topSignal.content || '',
3545
+ confidenceDelta: topSignal.confidenceDelta || topSignal.confidence_delta || 0,
3546
+ evaluatedAt: topSignal.evaluatedAt || topSignal.evaluated_at || '',
3547
+ why: topScore > 1 ? 'Largest confidence movement' : topScore > 0 ? 'Most substantive (no confidence change)' : 'Most recent',
3548
+ };
3549
+ }
3550
+ output.items = items;
3160
3551
  return {
3161
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
3552
+ content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
3162
3553
  details: {},
3163
3554
  };
3164
3555
  },
3165
3556
  },
3557
+ {
3558
+ name: 'get_changes',
3559
+ label: 'Get Changes',
3560
+ description: 'Get recent market changes detected server-side. Returns real price moves (>5¢), new contracts, and removed/settled contracts across Kalshi, Polymarket, and traditional markets. Use for situational awareness and discovering new opportunities.',
3561
+ parameters: Type.Object({
3562
+ hours: Type.Optional(Type.Number({ description: 'Hours of history (default 1)' })),
3563
+ }),
3564
+ execute: async (_id, p) => {
3565
+ const hours = p.hours || 1;
3566
+ const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
3567
+ const apiUrl = process.env.SF_API_URL || 'https://simplefunctions.dev';
3568
+ const res = await fetch(`${apiUrl}/api/changes?since=${encodeURIComponent(since)}&limit=100`);
3569
+ if (!res.ok)
3570
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `API error ${res.status}` }) }], details: {} };
3571
+ const data = await res.json();
3572
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3573
+ },
3574
+ },
3166
3575
  {
3167
3576
  name: 'explore_public',
3168
3577
  label: 'Explore Public Theses',
@@ -3487,17 +3896,49 @@ async function runPlainTextAgent(params) {
3487
3896
  }
3488
3897
  // ── System prompt ─────────────────────────────────────────────────────────
3489
3898
  const ctx = latestContext;
3490
- const edgesSummary = ctx.edges
3491
- ?.sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
3492
- .slice(0, 5)
3493
- .map((e) => ` ${(e.market || '').slice(0, 40)} | ${e.venue || 'kalshi'} | mkt ${e.marketPrice}\u00A2 | edge ${e.edge > 0 ? '+' : ''}${e.edge}`)
3494
- .join('\n') || ' (no edges)';
3495
- const nodesSummary = ctx.causalTree?.nodes
3496
- ?.filter((n) => n.depth === 0)
3497
- .map((n) => ` ${n.id} ${(n.label || '').slice(0, 40)} \u2014 ${Math.round(n.probability * 100)}%`)
3498
- .join('\n') || ' (no causal tree)';
3499
- const conf = typeof ctx.confidence === 'number' ? Math.round(ctx.confidence * 100) : 0;
3500
- 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.
3899
+ const isExplorerPlain = ctx._explorerMode || resolvedThesisId === '_explorer';
3900
+ let systemPrompt;
3901
+ if (isExplorerPlain) {
3902
+ const topEdges = (ctx.edges || [])
3903
+ .sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
3904
+ .slice(0, 5)
3905
+ .map((e) => ` ${(e.title || '').slice(0, 40)} | ${e.venue || 'kalshi'} | +${e.edge}`)
3906
+ .join('\n') || ' (no edges)';
3907
+ systemPrompt = `You are a prediction market research assistant in EXPLORER MODE — not bound to any thesis.
3908
+
3909
+ ## What you can do
3910
+ - query: LLM-enhanced market search
3911
+ - scan_markets: search Kalshi + Polymarket
3912
+ - get_markets: traditional markets (SPY, VIX, gold, oil)
3913
+ - explore_public: browse public theses
3914
+ - search_x, x_volume, x_news: X/Twitter signals
3915
+ - get_positions: portfolio positions
3916
+ - create_thesis: create a thesis when user forms a view
3917
+
3918
+ ## CRITICAL: When the user expresses a view worth tracking, use create_thesis. After creation, confirm and continue with the new thesis context.
3919
+
3920
+ ## Rules
3921
+ - Be concise. Use tools for fresh data.
3922
+ - Use Chinese if user writes Chinese, English if English.
3923
+ - Prices in cents (¢). P&L in dollars ($).
3924
+ ${config.tradingEnabled ? '- Trading ENABLED.' : '- Trading DISABLED.'}
3925
+
3926
+ ## Market snapshot
3927
+ Public edges:
3928
+ ${topEdges}`;
3929
+ }
3930
+ else {
3931
+ const edgesSummary = ctx.edges
3932
+ ?.sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
3933
+ .slice(0, 5)
3934
+ .map((e) => ` ${(e.market || '').slice(0, 40)} | ${e.venue || 'kalshi'} | mkt ${e.marketPrice}\u00A2 | edge ${e.edge > 0 ? '+' : ''}${e.edge}`)
3935
+ .join('\n') || ' (no edges)';
3936
+ const nodesSummary = ctx.causalTree?.nodes
3937
+ ?.filter((n) => n.depth === 0)
3938
+ .map((n) => ` ${n.id} ${(n.label || '').slice(0, 40)} \u2014 ${Math.round(n.probability * 100)}%`)
3939
+ .join('\n') || ' (no causal tree)';
3940
+ const conf = typeof ctx.confidence === 'number' ? Math.round(ctx.confidence * 100) : 0;
3941
+ 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.
3501
3942
 
3502
3943
  ## Framework
3503
3944
  Edge = thesis price - market price. Positive = market underprices. executableEdge = edge minus spread.
@@ -3540,6 +3981,7 @@ Top edges:
3540
3981
  ${edgesSummary}
3541
3982
 
3542
3983
  ${ctx.lastEvaluation?.summary ? `Latest eval: ${ctx.lastEvaluation.summary.slice(0, 300)}` : ''}`;
3984
+ }
3543
3985
  // ── Create agent ──────────────────────────────────────────────────────────
3544
3986
  const agent = new Agent({
3545
3987
  initialState: { systemPrompt, model, tools, thinkingLevel: 'off' },
@@ -3582,11 +4024,19 @@ ${ctx.lastEvaluation?.summary ? `Latest eval: ${ctx.lastEvaluation.summary.slice
3582
4024
  }
3583
4025
  });
3584
4026
  // ── Welcome ───────────────────────────────────────────────────────────────
3585
- const thesisText = ctx.thesis || ctx.rawThesis || 'N/A';
3586
- console.log(`SF Agent — ${resolvedThesisId.slice(0, 8)} | ${conf}% | ${currentModelName}`);
3587
- console.log(`Thesis: ${thesisText.length > 100 ? thesisText.slice(0, 100) + '...' : thesisText}`);
3588
- console.log(`Edges: ${(ctx.edges || []).length} | Status: ${ctx.status}`);
3589
- console.log('Type /help for commands, /exit to quit.\n');
4027
+ if (isExplorerPlain) {
4028
+ console.log(`SF Agent — Explorer mode | ${currentModelName}`);
4029
+ console.log(`Public edges: ${(ctx.edges || []).length}`);
4030
+ console.log('Ask anything about prediction markets. Type /help for commands, /exit to quit.\n');
4031
+ }
4032
+ else {
4033
+ const thesisText = ctx.thesis || ctx.rawThesis || 'N/A';
4034
+ const plainConf = typeof ctx.confidence === 'number' ? Math.round(ctx.confidence * 100) : 0;
4035
+ console.log(`SF Agent — ${resolvedThesisId.slice(0, 8)} | ${plainConf}% | ${currentModelName}`);
4036
+ console.log(`Thesis: ${thesisText.length > 100 ? thesisText.slice(0, 100) + '...' : thesisText}`);
4037
+ console.log(`Edges: ${(ctx.edges || []).length} | Status: ${ctx.status}`);
4038
+ console.log('Type /help for commands, /exit to quit.\n');
4039
+ }
3590
4040
  // ── REPL loop ─────────────────────────────────────────────────────────────
3591
4041
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' });
3592
4042
  rl.prompt();