@spfunctions/cli 1.7.19 → 1.7.22

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.
Files changed (180) hide show
  1. package/dist/101.index.js +1 -0
  2. package/dist/12.index.js +1 -0
  3. package/dist/160.index.js +1 -0
  4. package/dist/174.index.js +1 -0
  5. package/dist/278.index.js +6 -0
  6. package/dist/582.index.js +1 -0
  7. package/dist/641.index.js +324 -0
  8. package/dist/669.index.js +1 -0
  9. package/dist/722.index.js +1 -0
  10. package/dist/788.index.js +1 -0
  11. package/dist/816.index.js +12 -0
  12. package/dist/830.index.js +1 -0
  13. package/dist/921.index.js +1 -0
  14. package/dist/index.js +1 -833
  15. package/package.json +5 -2
  16. package/dist/cache.d.ts +0 -6
  17. package/dist/cache.js +0 -31
  18. package/dist/cache.test.d.ts +0 -1
  19. package/dist/cache.test.js +0 -73
  20. package/dist/client.d.ts +0 -56
  21. package/dist/client.js +0 -205
  22. package/dist/client.test.d.ts +0 -1
  23. package/dist/client.test.js +0 -89
  24. package/dist/commands/agent.d.ts +0 -20
  25. package/dist/commands/agent.js +0 -4119
  26. package/dist/commands/announcements.d.ts +0 -3
  27. package/dist/commands/announcements.js +0 -28
  28. package/dist/commands/augment.d.ts +0 -12
  29. package/dist/commands/augment.js +0 -56
  30. package/dist/commands/balance.d.ts +0 -3
  31. package/dist/commands/balance.js +0 -17
  32. package/dist/commands/book.d.ts +0 -17
  33. package/dist/commands/book.js +0 -220
  34. package/dist/commands/cancel.d.ts +0 -5
  35. package/dist/commands/cancel.js +0 -41
  36. package/dist/commands/context.d.ts +0 -6
  37. package/dist/commands/context.js +0 -208
  38. package/dist/commands/create.d.ts +0 -7
  39. package/dist/commands/create.js +0 -42
  40. package/dist/commands/dashboard.d.ts +0 -14
  41. package/dist/commands/dashboard.js +0 -215
  42. package/dist/commands/delta.d.ts +0 -16
  43. package/dist/commands/delta.js +0 -115
  44. package/dist/commands/edges.d.ts +0 -26
  45. package/dist/commands/edges.js +0 -246
  46. package/dist/commands/evaluate.d.ts +0 -4
  47. package/dist/commands/evaluate.js +0 -30
  48. package/dist/commands/explore.d.ts +0 -14
  49. package/dist/commands/explore.js +0 -116
  50. package/dist/commands/feed.d.ts +0 -13
  51. package/dist/commands/feed.js +0 -73
  52. package/dist/commands/fills.d.ts +0 -4
  53. package/dist/commands/fills.js +0 -29
  54. package/dist/commands/forecast.d.ts +0 -4
  55. package/dist/commands/forecast.js +0 -53
  56. package/dist/commands/get.d.ts +0 -5
  57. package/dist/commands/get.js +0 -98
  58. package/dist/commands/heartbeat.d.ts +0 -20
  59. package/dist/commands/heartbeat.js +0 -73
  60. package/dist/commands/history.d.ts +0 -3
  61. package/dist/commands/history.js +0 -38
  62. package/dist/commands/liquidity.d.ts +0 -14
  63. package/dist/commands/liquidity.js +0 -378
  64. package/dist/commands/list.d.ts +0 -5
  65. package/dist/commands/list.js +0 -38
  66. package/dist/commands/login.d.ts +0 -10
  67. package/dist/commands/login.js +0 -98
  68. package/dist/commands/markets.d.ts +0 -10
  69. package/dist/commands/markets.js +0 -39
  70. package/dist/commands/milestones.d.ts +0 -8
  71. package/dist/commands/milestones.js +0 -56
  72. package/dist/commands/orders.d.ts +0 -4
  73. package/dist/commands/orders.js +0 -28
  74. package/dist/commands/performance.d.ts +0 -11
  75. package/dist/commands/performance.js +0 -250
  76. package/dist/commands/positions.d.ts +0 -19
  77. package/dist/commands/positions.js +0 -294
  78. package/dist/commands/prompt.d.ts +0 -13
  79. package/dist/commands/prompt.js +0 -35
  80. package/dist/commands/publish.d.ts +0 -15
  81. package/dist/commands/publish.js +0 -39
  82. package/dist/commands/query.d.ts +0 -15
  83. package/dist/commands/query.js +0 -132
  84. package/dist/commands/rfq.d.ts +0 -5
  85. package/dist/commands/rfq.js +0 -35
  86. package/dist/commands/scan.d.ts +0 -11
  87. package/dist/commands/scan.js +0 -230
  88. package/dist/commands/schedule.d.ts +0 -3
  89. package/dist/commands/schedule.js +0 -38
  90. package/dist/commands/settlements.d.ts +0 -6
  91. package/dist/commands/settlements.js +0 -50
  92. package/dist/commands/setup.d.ts +0 -24
  93. package/dist/commands/setup.js +0 -700
  94. package/dist/commands/signal.d.ts +0 -6
  95. package/dist/commands/signal.js +0 -32
  96. package/dist/commands/strategies.d.ts +0 -11
  97. package/dist/commands/strategies.js +0 -130
  98. package/dist/commands/telegram.d.ts +0 -15
  99. package/dist/commands/telegram.js +0 -125
  100. package/dist/commands/trade.d.ts +0 -12
  101. package/dist/commands/trade.js +0 -112
  102. package/dist/commands/watch.d.ts +0 -19
  103. package/dist/commands/watch.js +0 -157
  104. package/dist/commands/whatif.d.ts +0 -17
  105. package/dist/commands/whatif.js +0 -209
  106. package/dist/commands/x.d.ts +0 -28
  107. package/dist/commands/x.js +0 -167
  108. package/dist/config.d.ts +0 -55
  109. package/dist/config.js +0 -139
  110. package/dist/config.test.d.ts +0 -1
  111. package/dist/config.test.js +0 -138
  112. package/dist/index.d.ts +0 -20
  113. package/dist/kalshi.d.ts +0 -144
  114. package/dist/kalshi.js +0 -498
  115. package/dist/polymarket.d.ts +0 -237
  116. package/dist/polymarket.js +0 -353
  117. package/dist/polymarket.test.d.ts +0 -1
  118. package/dist/polymarket.test.js +0 -424
  119. package/dist/share.d.ts +0 -4
  120. package/dist/share.js +0 -27
  121. package/dist/skills/loader.d.ts +0 -19
  122. package/dist/skills/loader.js +0 -86
  123. package/dist/telegram/agent-bridge.d.ts +0 -15
  124. package/dist/telegram/agent-bridge.js +0 -573
  125. package/dist/telegram/bot.d.ts +0 -10
  126. package/dist/telegram/bot.js +0 -297
  127. package/dist/telegram/commands.d.ts +0 -11
  128. package/dist/telegram/commands.js +0 -120
  129. package/dist/telegram/format.d.ts +0 -11
  130. package/dist/telegram/format.js +0 -51
  131. package/dist/telegram/format.test.d.ts +0 -1
  132. package/dist/telegram/format.test.js +0 -73
  133. package/dist/telegram/poller.d.ts +0 -6
  134. package/dist/telegram/poller.js +0 -32
  135. package/dist/topics.d.ts +0 -17
  136. package/dist/topics.js +0 -102
  137. package/dist/topics.test.d.ts +0 -1
  138. package/dist/topics.test.js +0 -131
  139. package/dist/tui/border.d.ts +0 -33
  140. package/dist/tui/border.js +0 -87
  141. package/dist/tui/chart.d.ts +0 -19
  142. package/dist/tui/chart.js +0 -117
  143. package/dist/tui/dashboard.d.ts +0 -9
  144. package/dist/tui/dashboard.js +0 -814
  145. package/dist/tui/layout.d.ts +0 -16
  146. package/dist/tui/layout.js +0 -41
  147. package/dist/tui/screen.d.ts +0 -33
  148. package/dist/tui/screen.js +0 -102
  149. package/dist/tui/state.d.ts +0 -40
  150. package/dist/tui/state.js +0 -36
  151. package/dist/tui/widgets/commandbar.d.ts +0 -8
  152. package/dist/tui/widgets/commandbar.js +0 -82
  153. package/dist/tui/widgets/detail.d.ts +0 -9
  154. package/dist/tui/widgets/detail.js +0 -151
  155. package/dist/tui/widgets/edges.d.ts +0 -4
  156. package/dist/tui/widgets/edges.js +0 -34
  157. package/dist/tui/widgets/liquidity.d.ts +0 -9
  158. package/dist/tui/widgets/liquidity.js +0 -142
  159. package/dist/tui/widgets/orders.d.ts +0 -4
  160. package/dist/tui/widgets/orders.js +0 -37
  161. package/dist/tui/widgets/portfolio.d.ts +0 -4
  162. package/dist/tui/widgets/portfolio.js +0 -59
  163. package/dist/tui/widgets/signals.d.ts +0 -4
  164. package/dist/tui/widgets/signals.js +0 -31
  165. package/dist/tui/widgets/statusbar.d.ts +0 -8
  166. package/dist/tui/widgets/statusbar.js +0 -72
  167. package/dist/tui/widgets/thesis.d.ts +0 -4
  168. package/dist/tui/widgets/thesis.js +0 -66
  169. package/dist/tui/widgets/trade.d.ts +0 -9
  170. package/dist/tui/widgets/trade.js +0 -117
  171. package/dist/tui/widgets/upcoming.d.ts +0 -4
  172. package/dist/tui/widgets/upcoming.js +0 -41
  173. package/dist/tui/widgets/whatif.d.ts +0 -7
  174. package/dist/tui/widgets/whatif.js +0 -113
  175. package/dist/types/output.d.ts +0 -412
  176. package/dist/types/output.js +0 -9
  177. package/dist/utils.d.ts +0 -52
  178. package/dist/utils.js +0 -146
  179. package/dist/utils.test.d.ts +0 -1
  180. package/dist/utils.test.js +0 -111
@@ -1,4119 +0,0 @@
1
- "use strict";
2
- /**
3
- * sf agent — Interactive TUI agent powered by pi-tui + pi-agent-core.
4
- *
5
- * Layout:
6
- * [Header overlay] — thesis id, confidence, model
7
- * [Spacer] — room for header
8
- * [Chat container] — messages (user, assistant, tool, system)
9
- * [Editor] — multi-line input with slash command autocomplete
10
- * [Spacer] — room for footer
11
- * [Footer overlay] — tokens, cost, tool count, /help hint
12
- *
13
- * Slash commands (bypass LLM):
14
- * /help /tree /edges /pos /eval /model /clear /exit
15
- */
16
- var __importDefault = (this && this.__importDefault) || function (mod) {
17
- return (mod && mod.__esModule) ? mod : { "default": mod };
18
- };
19
- Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.agentCommand = agentCommand;
21
- const fs_1 = __importDefault(require("fs"));
22
- const path_1 = __importDefault(require("path"));
23
- const os_1 = __importDefault(require("os"));
24
- const client_js_1 = require("../client.js");
25
- const kalshi_js_1 = require("../kalshi.js");
26
- const polymarket_js_1 = require("../polymarket.js");
27
- const topics_js_1 = require("../topics.js");
28
- const config_js_1 = require("../config.js");
29
- const loader_js_1 = require("../skills/loader.js");
30
- // ─── Session persistence ─────────────────────────────────────────────────────
31
- function getSessionDir() {
32
- return path_1.default.join(os_1.default.homedir(), '.sf', 'sessions');
33
- }
34
- function getSessionPath(thesisId) {
35
- return path_1.default.join(getSessionDir(), `${thesisId}.json`);
36
- }
37
- function loadSession(thesisId) {
38
- const p = getSessionPath(thesisId);
39
- try {
40
- if (fs_1.default.existsSync(p)) {
41
- return JSON.parse(fs_1.default.readFileSync(p, 'utf-8'));
42
- }
43
- }
44
- catch { /* corrupt file, ignore */ }
45
- return null;
46
- }
47
- function saveSession(thesisId, model, messages) {
48
- const dir = getSessionDir();
49
- fs_1.default.mkdirSync(dir, { recursive: true });
50
- fs_1.default.writeFileSync(getSessionPath(thesisId), JSON.stringify({
51
- thesisId,
52
- model,
53
- updatedAt: new Date().toISOString(),
54
- messages,
55
- }, null, 2));
56
- }
57
- // ─── ANSI 24-bit color helpers (no chalk dependency) ─────────────────────────
58
- const rgb = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
59
- const bgRgb = (r, g, b) => (s) => `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`;
60
- const bold = (s) => `\x1b[1m${s}\x1b[22m`;
61
- const dim = (s) => `\x1b[2m${s}\x1b[22m`;
62
- const italic = (s) => `\x1b[3m${s}\x1b[23m`;
63
- const underline = (s) => `\x1b[4m${s}\x1b[24m`;
64
- const strikethrough = (s) => `\x1b[9m${s}\x1b[29m`;
65
- const C = {
66
- emerald: rgb(16, 185, 129), // #10b981
67
- zinc200: rgb(228, 228, 231), // #e4e4e7
68
- zinc400: rgb(161, 161, 170), // #a1a1aa
69
- zinc600: rgb(82, 82, 91), // #52525b
70
- zinc800: rgb(39, 39, 42), // #27272a
71
- red: rgb(239, 68, 68), // #ef4444
72
- amber: rgb(245, 158, 11), // #f59e0b
73
- white: rgb(255, 255, 255), // #ffffff
74
- bgZinc900: bgRgb(24, 24, 27), // #18181b
75
- bgZinc800: bgRgb(39, 39, 42), // #27272a
76
- };
77
- // ─── Custom components ───────────────────────────────────────────────────────
78
- /** Mutable single-line component (TruncatedText is immutable) */
79
- function createMutableLine(piTui) {
80
- const { truncateToWidth, visibleWidth } = piTui;
81
- return class MutableLine {
82
- text;
83
- cachedWidth;
84
- cachedLines;
85
- constructor(text) {
86
- this.text = text;
87
- }
88
- setText(text) {
89
- this.text = text;
90
- this.cachedWidth = undefined;
91
- this.cachedLines = undefined;
92
- }
93
- invalidate() {
94
- this.cachedWidth = undefined;
95
- this.cachedLines = undefined;
96
- }
97
- render(width) {
98
- if (this.cachedLines && this.cachedWidth === width)
99
- return this.cachedLines;
100
- this.cachedWidth = width;
101
- this.cachedLines = [truncateToWidth(this.text, width)];
102
- return this.cachedLines;
103
- }
104
- };
105
- }
106
- /**
107
- * Header bar — trading terminal style.
108
- * Shows: thesis ID, confidence+delta, positions P&L, edge count, top edge
109
- */
110
- function createHeaderBar(piTui) {
111
- const { truncateToWidth, visibleWidth } = piTui;
112
- return class HeaderBar {
113
- thesisId = '';
114
- confidence = 0;
115
- confidenceDelta = 0;
116
- pnlDollars = 0;
117
- positionCount = 0;
118
- edgeCount = 0;
119
- topEdge = ''; // e.g. "RECESSION +21¢"
120
- cachedWidth;
121
- cachedLines;
122
- setFromContext(ctx, positions) {
123
- this.thesisId = (ctx.thesisId || '').slice(0, 8);
124
- this.confidence = typeof ctx.confidence === 'number'
125
- ? Math.round(ctx.confidence * 100)
126
- : (typeof ctx.confidence === 'string' ? Math.round(parseFloat(ctx.confidence) * 100) : 0);
127
- this.confidenceDelta = ctx.lastEvaluation?.confidenceDelta
128
- ? Math.round(ctx.lastEvaluation.confidenceDelta * 100)
129
- : 0;
130
- this.edgeCount = (ctx.edges || []).length;
131
- // Top edge by absolute size
132
- const edges = ctx.edges || [];
133
- if (edges.length > 0) {
134
- const top = [...edges].sort((a, b) => Math.abs(b.edge || b.edgeSize || 0) - Math.abs(a.edge || a.edgeSize || 0))[0];
135
- const name = (top.market || top.marketTitle || top.marketId || '').slice(0, 20);
136
- const edge = top.edge || top.edgeSize || 0;
137
- this.topEdge = `${name} ${edge > 0 ? '+' : ''}${Math.round(edge)}\u00A2`;
138
- }
139
- // P&L from positions
140
- if (positions && positions.length > 0) {
141
- this.positionCount = positions.length;
142
- this.pnlDollars = positions.reduce((sum, p) => {
143
- const pnl = p.unrealized_pnl || 0;
144
- return sum + pnl;
145
- }, 0) / 100; // cents → dollars
146
- }
147
- this.cachedWidth = undefined;
148
- this.cachedLines = undefined;
149
- }
150
- updateConfidence(newConf, delta) {
151
- this.confidence = Math.round(newConf * 100);
152
- this.confidenceDelta = Math.round(delta * 100);
153
- this.cachedWidth = undefined;
154
- this.cachedLines = undefined;
155
- }
156
- invalidate() {
157
- this.cachedWidth = undefined;
158
- this.cachedLines = undefined;
159
- }
160
- // Keep legacy update() for compatibility with /switch etc.
161
- update(left, center, right) {
162
- this.cachedWidth = undefined;
163
- this.cachedLines = undefined;
164
- }
165
- render(width) {
166
- if (this.cachedLines && this.cachedWidth === width)
167
- return this.cachedLines;
168
- this.cachedWidth = width;
169
- // Build segments
170
- const id = C.emerald(bold(this.thesisId));
171
- // Confidence with arrow
172
- const arrow = this.confidenceDelta > 0 ? '\u25B2' : this.confidenceDelta < 0 ? '\u25BC' : '\u2500';
173
- const arrowColor = this.confidenceDelta > 0 ? C.emerald : this.confidenceDelta < 0 ? C.red : C.zinc600;
174
- const deltaStr = this.confidenceDelta !== 0 ? ` (${this.confidenceDelta > 0 ? '+' : ''}${this.confidenceDelta})` : '';
175
- const conf = arrowColor(`${arrow} ${this.confidence}%${deltaStr}`);
176
- // P&L
177
- let pnl = '';
178
- if (this.positionCount > 0) {
179
- const pnlStr = this.pnlDollars >= 0
180
- ? C.emerald(`+$${this.pnlDollars.toFixed(2)}`)
181
- : C.red(`-$${Math.abs(this.pnlDollars).toFixed(2)}`);
182
- pnl = C.zinc600(`${this.positionCount} pos `) + pnlStr;
183
- }
184
- // Edges
185
- const edges = C.zinc600(`${this.edgeCount} edges`);
186
- // Top edge
187
- const top = this.topEdge ? C.zinc400(this.topEdge) : '';
188
- // Assemble with separators
189
- const sep = C.zinc600(' \u2502 ');
190
- const parts = [id, conf, pnl, edges, top].filter(Boolean);
191
- const content = parts.join(sep);
192
- let line = C.bgZinc900(' ' + truncateToWidth(content, width - 2, '') + ' ');
193
- const lineVw = visibleWidth(line);
194
- if (lineVw < width) {
195
- line = line + C.bgZinc900(' '.repeat(width - lineVw));
196
- }
197
- this.cachedLines = [line];
198
- return this.cachedLines;
199
- }
200
- };
201
- }
202
- /** Combined footer bar: thesis info (line 1) + model/exchange (line 2) */
203
- function createFooterBar(piTui) {
204
- const { truncateToWidth, visibleWidth } = piTui;
205
- return class FooterBar {
206
- // Thesis info (was HeaderBar)
207
- thesisId = '';
208
- confidence = 0;
209
- confidenceDelta = 0;
210
- pnlDollars = 0;
211
- positionCount = 0;
212
- edgeCount = 0;
213
- topEdge = '';
214
- // Model info
215
- tokens = 0;
216
- cost = 0;
217
- toolCount = 0;
218
- modelName = '';
219
- tradingEnabled = false;
220
- exchangeOpen = null;
221
- cachedWidth;
222
- cachedLines;
223
- isExplorer = false;
224
- setFromContext(ctx, positions) {
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
- }
254
- }
255
- if (positions && positions.length > 0) {
256
- this.positionCount = positions.length;
257
- this.pnlDollars = positions.reduce((sum, p) => sum + (p.unrealized_pnl || 0), 0) / 100;
258
- }
259
- this.cachedWidth = undefined;
260
- this.cachedLines = undefined;
261
- }
262
- updateConfidence(newConf, delta) {
263
- this.confidence = Math.round(newConf * 100);
264
- this.confidenceDelta = Math.round(delta * 100);
265
- this.cachedWidth = undefined;
266
- this.cachedLines = undefined;
267
- }
268
- invalidate() {
269
- this.cachedWidth = undefined;
270
- this.cachedLines = undefined;
271
- }
272
- update() {
273
- this.cachedWidth = undefined;
274
- this.cachedLines = undefined;
275
- }
276
- render(width) {
277
- if (this.cachedLines && this.cachedWidth === width)
278
- return this.cachedLines;
279
- this.cachedWidth = width;
280
- // Line 1: thesis info (or explorer mode)
281
- const sep = C.zinc600(' \u2502 ');
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
- }
313
- let line1 = C.bgZinc800(' ' + truncateToWidth(line1Parts.join(sep), width - 2, '') + ' ');
314
- const l1vw = visibleWidth(line1);
315
- if (l1vw < width)
316
- line1 += C.bgZinc800(' '.repeat(width - l1vw));
317
- // Line 2: model + exchange
318
- const model = C.zinc600(this.modelName.split('/').pop() || this.modelName);
319
- const tokStr = this.tokens >= 1000 ? `${(this.tokens / 1000).toFixed(1)}k` : `${this.tokens}`;
320
- const tokens = C.zinc600(`${tokStr} tok`);
321
- const exchange = this.exchangeOpen === true ? C.emerald('OPEN') : this.exchangeOpen === false ? C.red('CLOSED') : C.zinc600('...');
322
- const trading = this.tradingEnabled ? C.amber('trading') : C.zinc600('read-only');
323
- const help = C.zinc600('/help');
324
- const leftText = [model, tokens, exchange, trading].join(sep);
325
- const lw = visibleWidth(leftText);
326
- const rw = visibleWidth(help);
327
- const gap = Math.max(1, width - lw - rw - 2);
328
- let line2 = C.bgZinc900(' ' + leftText + ' '.repeat(gap) + help + ' ');
329
- const l2vw = visibleWidth(line2);
330
- if (l2vw < width)
331
- line2 += C.bgZinc900(' '.repeat(width - l2vw));
332
- this.cachedLines = [line1, line2];
333
- return this.cachedLines;
334
- }
335
- };
336
- }
337
- // ─── Formatted renderers ─────────────────────────────────────────────────────
338
- function renderCausalTree(context, piTui) {
339
- const tree = context.causalTree;
340
- if (!tree?.nodes?.length)
341
- return C.zinc600(' No causal tree data');
342
- const lines = [];
343
- for (const node of tree.nodes) {
344
- const id = node.id || '';
345
- const label = node.label || node.description || '';
346
- const prob = typeof node.probability === 'number'
347
- ? Math.round(node.probability * 100)
348
- : (typeof node.impliedProbability === 'number' ? Math.round(node.impliedProbability * 100) : null);
349
- const depth = (id.match(/\./g) || []).length;
350
- const indent = ' '.repeat(depth + 1);
351
- if (prob !== null) {
352
- // Progress bar: 10 chars
353
- const filled = Math.round(prob / 10);
354
- const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
355
- const probColor = prob >= 70 ? C.emerald : prob >= 40 ? C.amber : C.red;
356
- // Dots to pad between label and percentage
357
- const labelPart = `${indent}${C.zinc600(id)} ${C.zinc400(label)} `;
358
- const probPart = ` ${probColor(`${prob}%`)} ${probColor(bar)}`;
359
- lines.push(labelPart + probPart);
360
- }
361
- else {
362
- lines.push(`${indent}${C.zinc600(id)} ${C.zinc400(label)}`);
363
- }
364
- }
365
- return lines.join('\n');
366
- }
367
- function renderEdges(context, piTui) {
368
- const edges = context.edges;
369
- if (!edges?.length)
370
- return C.zinc600(' No edge data');
371
- const positions = context._positions || [];
372
- const lines = [];
373
- for (const e of edges) {
374
- // Context API field names: market, marketId, thesisPrice, edge, orderbook.spread, orderbook.liquidityScore
375
- const name = (e.market || e.marketId || '').slice(0, 18).padEnd(18);
376
- const marketStr = typeof e.marketPrice === 'number' ? `${e.marketPrice}\u00A2` : '?';
377
- const thesisStr = typeof e.thesisPrice === 'number' ? `${e.thesisPrice}\u00A2` : '?';
378
- const edgeVal = typeof e.edge === 'number' ? (e.edge > 0 ? `+${e.edge}` : `${e.edge}`) : '?';
379
- const ob = e.orderbook || {};
380
- const spreadStr = typeof ob.spread === 'number' ? `${ob.spread}\u00A2` : '?';
381
- const liq = ob.liquidityScore || 'low';
382
- const liqBars = liq === 'high' ? '\u25A0\u25A0\u25A0' : liq === 'medium' ? '\u25A0\u25A0 ' : '\u25A0 ';
383
- const liqColor = liq === 'high' ? C.emerald : liq === 'medium' ? C.amber : C.red;
384
- // Check if we have a position on this edge (match by marketId prefix in ticker)
385
- const pos = positions.find((p) => p.ticker === e.marketId ||
386
- (e.marketId && p.ticker?.includes(e.marketId)));
387
- let posStr = C.zinc600('\u2014');
388
- if (pos) {
389
- const side = pos.side?.toUpperCase() || 'YES';
390
- const pnl = typeof pos.unrealized_pnl === 'number'
391
- ? (pos.unrealized_pnl >= 0 ? C.emerald(`+$${(pos.unrealized_pnl / 100).toFixed(0)}`) : C.red(`-$${(Math.abs(pos.unrealized_pnl) / 100).toFixed(0)}`))
392
- : '';
393
- posStr = C.emerald(`${side} (${pos.quantity}@${pos.average_price_paid}\u00A2 ${pnl})`);
394
- }
395
- lines.push(` ${C.zinc200(name)} ${C.zinc400(marketStr)} \u2192 ${C.zinc400(thesisStr)} edge ${edgeVal.includes('+') ? C.emerald(edgeVal) : C.red(edgeVal)} spread ${C.zinc600(spreadStr)} ${liqColor(liqBars)} ${liqColor(liq.padEnd(4))} ${posStr}`);
396
- }
397
- return lines.join('\n');
398
- }
399
- function renderPositions(positions) {
400
- if (!positions?.length)
401
- return C.zinc600(' No positions');
402
- const lines = [];
403
- let totalPnl = 0;
404
- for (const p of positions) {
405
- const ticker = (p.ticker || '').slice(0, 18).padEnd(18);
406
- const side = (p.side || 'yes').toUpperCase().padEnd(3);
407
- const qty = String(p.quantity || 0);
408
- const avg = `${p.average_price_paid || 0}\u00A2`;
409
- const now = typeof p.current_value === 'number' && p.current_value > 0
410
- ? `${p.current_value}\u00A2`
411
- : '?\u00A2';
412
- const pnlCents = p.unrealized_pnl || 0;
413
- totalPnl += pnlCents;
414
- const pnlDollars = (pnlCents / 100).toFixed(2);
415
- const pnlStr = pnlCents >= 0
416
- ? C.emerald(`+$${pnlDollars}`)
417
- : C.red(`-$${Math.abs(parseFloat(pnlDollars)).toFixed(2)}`);
418
- const arrow = pnlCents >= 0 ? C.emerald('\u25B2') : C.red('\u25BC');
419
- lines.push(` ${C.zinc200(ticker)} ${C.zinc400(side)} ${C.zinc400(qty)} @ ${C.zinc400(avg)} now ${C.zinc200(now)} ${pnlStr} ${arrow}`);
420
- }
421
- const totalDollars = (totalPnl / 100).toFixed(2);
422
- lines.push(C.zinc600(' ' + '\u2500'.repeat(40)));
423
- lines.push(totalPnl >= 0
424
- ? ` Total P&L: ${C.emerald(bold(`+$${totalDollars}`))}`
425
- : ` Total P&L: ${C.red(bold(`-$${Math.abs(parseFloat(totalDollars)).toFixed(2)}`))}`);
426
- return lines.join('\n');
427
- }
428
- // ─── Thesis selector (arrow keys + enter, like Claude Code) ─────────────────
429
- async function selectThesis(theses, includeExplorer = false) {
430
- return new Promise((resolve) => {
431
- let selected = 0;
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) {
437
- const conf = typeof t.confidence === 'number' ? Math.round(t.confidence * 100) : 0;
438
- const title = (t.rawThesis || t.thesis || t.title || '').slice(0, 55);
439
- items.push({ id: t.id, conf, title });
440
- }
441
- const write = process.stdout.write.bind(process.stdout);
442
- // Use alternate screen buffer for clean rendering (like Claude Code)
443
- write('\x1b[?1049h'); // enter alternate screen
444
- write('\x1b[?25l'); // hide cursor
445
- function render() {
446
- write('\x1b[H\x1b[2J'); // cursor home + clear screen
447
- write('\n \x1b[2mSelect thesis\x1b[22m\n\n');
448
- for (let i = 0; i < items.length; i++) {
449
- const item = items[i];
450
- const sel = i === selected;
451
- const cursor = sel ? '\x1b[38;2;16;185;129m › \x1b[39m' : ' ';
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
- }
462
- }
463
- write(`\n \x1b[38;2;55;55;60m↑↓ navigate · enter select\x1b[39m`);
464
- }
465
- render();
466
- if (process.stdin.isTTY)
467
- process.stdin.setRawMode(true);
468
- process.stdin.resume();
469
- process.stdin.setEncoding('utf8');
470
- const onKey = (key) => {
471
- const buf = Buffer.from(key);
472
- if (buf[0] === 0x1b && buf[1] === 0x5b && buf[2] === 0x41) {
473
- selected = (selected - 1 + items.length) % items.length;
474
- render();
475
- }
476
- else if (buf[0] === 0x1b && buf[1] === 0x5b && buf[2] === 0x42) {
477
- selected = (selected + 1) % items.length;
478
- render();
479
- }
480
- else if (key === 'k') {
481
- selected = (selected - 1 + items.length) % items.length;
482
- render();
483
- }
484
- else if (key === 'j') {
485
- selected = (selected + 1) % items.length;
486
- render();
487
- }
488
- else if (key === '\r' || key === '\n') {
489
- cleanup();
490
- resolve(items[selected].id);
491
- }
492
- else if (buf[0] === 0x03) {
493
- cleanup();
494
- process.exit(0);
495
- }
496
- };
497
- function cleanup() {
498
- process.stdin.removeListener('data', onKey);
499
- if (process.stdin.isTTY)
500
- process.stdin.setRawMode(false);
501
- process.stdin.pause();
502
- write('\x1b[?25h'); // show cursor
503
- write('\x1b[?1049l'); // exit alternate screen — restores original content
504
- }
505
- process.stdin.on('data', onKey);
506
- });
507
- }
508
- // ─── Main command ────────────────────────────────────────────────────────────
509
- async function agentCommand(thesisId, opts) {
510
- // ── Validate API keys ──────────────────────────────────────────────────────
511
- const directOrKey = opts?.modelKey || process.env.OPENROUTER_API_KEY;
512
- const sfApiKey = process.env.SF_API_KEY;
513
- const sfApiUrl = process.env.SF_API_URL || 'https://simplefunctions.dev';
514
- // Proxy mode: no local OpenRouter key, but have SF API key → route through server
515
- const useProxy = !directOrKey && !!sfApiKey;
516
- const openrouterKey = directOrKey || sfApiKey; // SF key used as Bearer for proxy
517
- const llmBaseUrl = useProxy ? `${sfApiUrl}/api/proxy` : 'https://openrouter.ai/api/v1';
518
- if (!openrouterKey) {
519
- console.error('Need an API key to power the agent LLM.');
520
- console.error('');
521
- console.error(' Option 1 (recommended): sf login');
522
- console.error(' Option 2: Get an OpenRouter key at https://openrouter.ai/keys');
523
- console.error(' export OPENROUTER_API_KEY=sk-or-v1-...');
524
- console.error(' sf agent --model-key sk-or-v1-...');
525
- console.error(' sf setup (saves to ~/.sf/config.json)');
526
- process.exit(1);
527
- }
528
- // Pre-flight: validate key (skip for proxy mode — server validates)
529
- if (!useProxy) {
530
- try {
531
- const checkRes = await fetch('https://openrouter.ai/api/v1/auth/key', {
532
- headers: { 'Authorization': `Bearer ${openrouterKey}` },
533
- signal: AbortSignal.timeout(8000),
534
- });
535
- if (!checkRes.ok) {
536
- console.error('OpenRouter API key is invalid or expired.');
537
- console.error('Get a new key at https://openrouter.ai/keys');
538
- process.exit(1);
539
- }
540
- }
541
- catch (err) {
542
- const msg = err instanceof Error ? err.message : String(err);
543
- if (!msg.includes('timeout')) {
544
- console.warn(`Warning: Could not verify OpenRouter key (${msg}). Continuing anyway.`);
545
- }
546
- }
547
- }
548
- else {
549
- console.log(' \x1b[2mUsing SimpleFunctions LLM proxy (no OpenRouter key needed)\x1b[22m');
550
- }
551
- const sfClient = new client_js_1.SFClient();
552
- // ── Resolve thesis ID (interactive selection if needed) ─────────────────────
553
- let resolvedThesisId = thesisId || null;
554
- let explorerMode = false;
555
- if (!resolvedThesisId) {
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
- }
566
- if (active.length === 0) {
567
- // No theses — go straight to explorer mode
568
- explorerMode = true;
569
- }
570
- else if (active.length === 1) {
571
- resolvedThesisId = active[0].id;
572
- }
573
- else if (process.stdin.isTTY && !opts?.noTui) {
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
- }
582
- }
583
- else {
584
- // Non-interactive (--plain, telegram, piped) — use first active
585
- resolvedThesisId = active[0].id;
586
- }
587
- }
588
- // ── Fetch initial context ──────────────────────────────────────────────────
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
- }
598
- // ── Branch: plain-text mode ────────────────────────────────────────────────
599
- if (opts?.noTui) {
600
- return runPlainTextAgent({ openrouterKey, sfClient, resolvedThesisId: resolvedThesisId || '_explorer', latestContext, useProxy, llmBaseUrl, sfApiKey, sfApiUrl, opts });
601
- }
602
- // ── Dynamic imports (all ESM-only packages) ────────────────────────────────
603
- const piTui = await import('@mariozechner/pi-tui');
604
- const piAi = await import('@mariozechner/pi-ai');
605
- const piAgent = await import('@mariozechner/pi-agent-core');
606
- const { TUI, ProcessTerminal, Container, Text, Markdown, Editor, Loader, Spacer, CombinedAutocompleteProvider, truncateToWidth, visibleWidth, } = piTui;
607
- const { getModel, streamSimple, Type } = piAi;
608
- const { Agent } = piAgent;
609
- // ── Component class factories (need piTui ref) ─────────────────────────────
610
- const MutableLine = createMutableLine(piTui);
611
- const FooterBar = createFooterBar(piTui);
612
- // ── Model setup ────────────────────────────────────────────────────────────
613
- const rawModelName = opts?.model || 'anthropic/claude-sonnet-4.6';
614
- let currentModelName = rawModelName.replace(/^openrouter\//, '');
615
- function resolveModel(name) {
616
- let m;
617
- try {
618
- m = getModel('openrouter', name);
619
- }
620
- catch {
621
- m = {
622
- modelId: name,
623
- provider: 'openrouter',
624
- api: 'openai-completions',
625
- baseUrl: 'https://openrouter.ai/api/v1',
626
- id: name,
627
- name: name,
628
- inputPrice: 0,
629
- outputPrice: 0,
630
- contextWindow: 200000,
631
- supportsImages: true,
632
- supportsTools: true,
633
- };
634
- }
635
- // Override baseUrl in proxy mode
636
- if (useProxy)
637
- m.baseUrl = llmBaseUrl;
638
- return m;
639
- }
640
- let model = resolveModel(currentModelName);
641
- // ── Tracking state ─────────────────────────────────────────────────────────
642
- let totalTokens = 0;
643
- let totalCost = 0;
644
- let totalToolCalls = 0;
645
- let isProcessing = false;
646
- // Cache for positions (fetched by /pos or get_positions tool)
647
- let cachedPositions = null;
648
- // ── Heartbeat polling state ───────────────────────────────────────────────
649
- // Background poll delta endpoint every 60s.
650
- // If confidence changed ≥ 3%, auto-trigger agent analysis.
651
- // If agent is busy (isProcessing), queue and deliver after agent finishes.
652
- let lastPollTimestamp = new Date().toISOString();
653
- let pendingHeartbeatDelta = null; // queued delta when agent is busy
654
- let heartbeatPollTimer = null;
655
- // ── Inline confirmation mechanism ─────────────────────────────────────────
656
- // Tools can call promptUser() during execution to ask the user a question.
657
- // This temporarily unlocks the editor, waits for input, then resumes.
658
- let pendingPrompt = null;
659
- // ── Setup TUI ──────────────────────────────────────────────────────────────
660
- const terminal = new ProcessTerminal();
661
- const tui = new TUI(terminal);
662
- // Markdown theme for assistant messages
663
- const mdTheme = {
664
- heading: (s) => C.zinc200(bold(s)),
665
- link: (s) => C.emerald(s),
666
- linkUrl: (s) => C.zinc600(s),
667
- code: (s) => C.zinc200(s),
668
- codeBlock: (s) => C.zinc400(s),
669
- codeBlockBorder: (s) => C.zinc600(s),
670
- quote: (s) => C.zinc400(s),
671
- quoteBorder: (s) => C.zinc600(s),
672
- hr: (s) => C.zinc600(s),
673
- listBullet: (s) => C.emerald(s),
674
- bold: (s) => bold(s),
675
- italic: (s) => italic(s),
676
- strikethrough: (s) => strikethrough(s),
677
- underline: (s) => underline(s),
678
- };
679
- const mdDefaultStyle = {
680
- color: (s) => C.zinc400(s),
681
- };
682
- // Editor theme — use dim zinc borders instead of default green
683
- const editorTheme = {
684
- borderColor: (s) => `\x1b[38;2;50;50;55m${s}\x1b[39m`,
685
- selectList: {
686
- selectedPrefix: (s) => C.emerald(s),
687
- selectedText: (s) => C.zinc200(s),
688
- description: (s) => C.zinc600(s),
689
- scrollInfo: (s) => C.zinc600(s),
690
- noMatch: (s) => C.zinc600(s),
691
- },
692
- };
693
- // ── Build components ───────────────────────────────────────────────────────
694
- // No header bar — all info in footer (2 lines)
695
- const footerBar = new FooterBar();
696
- footerBar.modelName = currentModelName;
697
- footerBar.tradingEnabled = (0, config_js_1.loadConfig)().tradingEnabled || false;
698
- // Fetch positions for footer P&L (non-blocking, best-effort)
699
- let initialPositions = null;
700
- try {
701
- initialPositions = await (0, kalshi_js_1.getPositions)();
702
- if (initialPositions) {
703
- for (const pos of initialPositions) {
704
- const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
705
- if (livePrice !== null) {
706
- pos.current_value = livePrice;
707
- pos.unrealized_pnl = Math.round((livePrice - pos.average_price_paid) * pos.quantity);
708
- }
709
- }
710
- }
711
- }
712
- catch { /* positions not available, fine */ }
713
- footerBar.setFromContext(latestContext, initialPositions || undefined);
714
- // Fetch exchange status for footer (non-blocking)
715
- fetch('https://api.elections.kalshi.com/trade-api/v2/exchange/status', { headers: { 'Accept': 'application/json' } })
716
- .then(r => r.json())
717
- .then(d => { footerBar.exchangeOpen = !!d.exchange_active; footerBar.update(); tui.requestRender(); })
718
- .catch(() => { });
719
- const topSpacer = new Spacer(1);
720
- const bottomSpacer = new Spacer(1);
721
- const chatContainer = new Container();
722
- const editor = new Editor(tui, editorTheme, { paddingX: 1 });
723
- // Slash command autocomplete
724
- const slashCommands = [
725
- { name: 'help', description: 'Show available commands' },
726
- { name: 'tree', description: 'Display causal tree' },
727
- { name: 'edges', description: 'Display edge/spread table' },
728
- { name: 'pos', description: 'Display Kalshi positions' },
729
- { name: 'eval', description: 'Trigger deep evaluation' },
730
- { name: 'switch', description: 'Switch thesis (e.g. /switch f582bf76)' },
731
- { name: 'compact', description: 'Compress conversation history' },
732
- { name: 'new', description: 'Start fresh session' },
733
- { name: 'model', description: 'Switch model (e.g. /model anthropic/claude-sonnet-4)' },
734
- { name: 'env', description: 'Show environment variable status' },
735
- { name: 'clear', description: 'Clear screen (keeps history)' },
736
- { name: 'exit', description: 'Exit agent (auto-saves)' },
737
- ];
738
- // Add trading commands if enabled
739
- if ((0, config_js_1.loadConfig)().tradingEnabled) {
740
- slashCommands.splice(-2, 0, // insert before /clear and /exit
741
- { name: 'buy', description: 'TICKER QTY PRICE — quick buy' }, { name: 'sell', description: 'TICKER QTY PRICE — quick sell' }, { name: 'cancel', description: 'ORDER_ID — cancel order' });
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
- }
749
- const autocompleteProvider = new CombinedAutocompleteProvider(slashCommands, process.cwd());
750
- editor.setAutocompleteProvider(autocompleteProvider);
751
- // Assemble TUI tree
752
- tui.addChild(topSpacer);
753
- tui.addChild(chatContainer);
754
- tui.addChild(editor);
755
- tui.addChild(bottomSpacer);
756
- // Focus on editor
757
- tui.setFocus(editor);
758
- // ── Footer overlay (2-line: thesis info + model/exchange) ──────────────────
759
- const footerOverlay = tui.showOverlay(footerBar, {
760
- anchor: 'bottom-left',
761
- width: '100%',
762
- nonCapturing: true,
763
- });
764
- // ── Helper: add system text to chat ────────────────────────────────────────
765
- function addSystemText(content) {
766
- const text = new Text(content, 1, 0);
767
- chatContainer.addChild(text);
768
- tui.requestRender();
769
- }
770
- function addSpacer() {
771
- chatContainer.addChild(new Spacer(1));
772
- }
773
- /**
774
- * Ask the user a question during tool execution.
775
- * Temporarily unlocks the editor, waits for input, then resumes.
776
- * Used for order confirmations and other dangerous operations.
777
- */
778
- function promptUser(question) {
779
- return new Promise(resolve => {
780
- addSystemText(C.amber(bold('\u26A0 ')) + C.zinc200(question));
781
- addSpacer();
782
- tui.requestRender();
783
- pendingPrompt = { resolve };
784
- });
785
- }
786
- // ── Define agent tools (same as before) ────────────────────────────────────
787
- const thesisIdParam = Type.Object({
788
- thesisId: Type.String({ description: 'Thesis ID (short or full UUID)' }),
789
- });
790
- const signalParams = Type.Object({
791
- thesisId: Type.String({ description: 'Thesis ID' }),
792
- content: Type.String({ description: 'Signal content' }),
793
- type: Type.Optional(Type.String({ description: 'Signal type: news, user_note, external. Default: user_note' })),
794
- });
795
- const scanParams = Type.Object({
796
- query: Type.Optional(Type.String({ description: 'Keyword search for Kalshi markets' })),
797
- series: Type.Optional(Type.String({ description: 'Kalshi series ticker (e.g. KXWTIMAX)' })),
798
- market: Type.Optional(Type.String({ description: 'Specific market ticker' })),
799
- });
800
- const webSearchParams = Type.Object({
801
- query: Type.String({ description: 'Search keywords' }),
802
- });
803
- const emptyParams = Type.Object({});
804
- const tools = [
805
- {
806
- name: 'get_context',
807
- label: 'Get Context',
808
- description: 'Get thesis snapshot: causal tree, edge prices, last evaluation, confidence',
809
- parameters: thesisIdParam,
810
- execute: async (_toolCallId, params) => {
811
- const ctx = await sfClient.getContext(params.thesisId);
812
- latestContext = ctx;
813
- footerBar.setFromContext(ctx, initialPositions || undefined);
814
- tui.requestRender();
815
- return {
816
- content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }],
817
- details: {},
818
- };
819
- },
820
- },
821
- {
822
- name: 'global_context',
823
- label: 'Market Snapshot',
824
- description: 'Global market snapshot — top movers, expiring contracts, milestones, liquidity, signals. No thesis needed.',
825
- parameters: emptyParams,
826
- execute: async () => {
827
- const { fetchGlobalContext } = await import('../client.js');
828
- const data = await fetchGlobalContext();
829
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
830
- },
831
- },
832
- {
833
- name: 'query',
834
- label: 'Query',
835
- description: 'LLM-enhanced prediction market knowledge search. Ask any question about prediction markets, macro, geopolitics. Returns structured answer with live market prices, thesis data, and key factors.',
836
- parameters: Type.Object({
837
- q: Type.String({ description: 'Natural language query (e.g. "iran oil prices", "fed rate cut 2026")' }),
838
- }),
839
- execute: async (_toolCallId, params) => {
840
- const { fetchQuery } = await import('../client.js');
841
- const data = await fetchQuery(params.q);
842
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
843
- },
844
- },
845
- {
846
- name: 'get_markets',
847
- label: 'Traditional Markets',
848
- description: 'Get traditional market prices via Databento: S&P 500 (SPY), VIX (VIXY), 20Y Treasury (TLT), Gold (GLD), Oil (USO). Daily close + 1-day change.',
849
- parameters: emptyParams,
850
- execute: async () => {
851
- const { fetchTraditionalMarkets } = await import('../client.js');
852
- const data = await fetchTraditionalMarkets();
853
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
854
- },
855
- },
856
- {
857
- name: 'inject_signal',
858
- label: 'Inject Signal',
859
- description: 'Inject a signal into the thesis (news, note, external event)',
860
- parameters: signalParams,
861
- execute: async (_toolCallId, params) => {
862
- const result = await sfClient.injectSignal(params.thesisId, params.type || 'user_note', params.content);
863
- return {
864
- content: [{ type: 'text', text: JSON.stringify(result) }],
865
- details: {},
866
- };
867
- },
868
- },
869
- {
870
- name: 'trigger_evaluation',
871
- label: 'Evaluate',
872
- description: 'Trigger a deep evaluation cycle (heavy model, takes longer)',
873
- parameters: thesisIdParam,
874
- execute: async (_toolCallId, params) => {
875
- const result = await sfClient.evaluate(params.thesisId);
876
- // Show confidence change prominently
877
- if (result.evaluation?.confidenceDelta && Math.abs(result.evaluation.confidenceDelta) >= 0.01) {
878
- const delta = result.evaluation.confidenceDelta;
879
- const prev = Math.round((result.evaluation.previousConfidence || 0) * 100);
880
- const now = Math.round((result.evaluation.newConfidence || 0) * 100);
881
- const arrow = delta > 0 ? '\u25B2' : '\u25BC';
882
- const color = delta > 0 ? C.emerald : C.red;
883
- addSystemText(color(` ${arrow} Confidence ${prev}% \u2192 ${now}% (${delta > 0 ? '+' : ''}${Math.round(delta * 100)})`));
884
- addSpacer();
885
- // Update header
886
- footerBar.updateConfidence(result.evaluation.newConfidence, delta);
887
- tui.requestRender();
888
- }
889
- // Refresh context after eval
890
- try {
891
- latestContext = await sfClient.getContext(params.thesisId);
892
- footerBar.setFromContext(latestContext, initialPositions || undefined);
893
- tui.requestRender();
894
- }
895
- catch { }
896
- return {
897
- content: [{ type: 'text', text: JSON.stringify(result) }],
898
- details: {},
899
- };
900
- },
901
- },
902
- {
903
- name: 'scan_markets',
904
- label: 'Scan Markets',
905
- description: 'Search Kalshi + Polymarket prediction markets. Provide exactly one of: query (keyword search), series (Kalshi series ticker), or market (specific Kalshi ticker). Keyword search returns results from BOTH venues.',
906
- parameters: scanParams,
907
- execute: async (_toolCallId, params) => {
908
- if (params.market) {
909
- const result = await (0, client_js_1.kalshiFetchMarket)(params.market);
910
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
911
- }
912
- if (params.series) {
913
- const result = await (0, client_js_1.kalshiFetchMarketsBySeries)(params.series);
914
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
915
- }
916
- if (params.query) {
917
- // Kalshi: keyword grep on series
918
- const series = await (0, client_js_1.kalshiFetchAllSeries)();
919
- const keywords = params.query.toLowerCase().split(/\s+/);
920
- const kalshiMatched = series
921
- .filter((s) => keywords.some((kw) => (s.title || '').toLowerCase().includes(kw) ||
922
- (s.ticker || '').toLowerCase().includes(kw)))
923
- .filter((s) => parseFloat(s.volume_24h_fp || s.volume_fp || '0') > 0)
924
- .sort((a, b) => parseFloat(b.volume_24h_fp || b.volume_fp || '0') - parseFloat(a.volume_24h_fp || a.volume_fp || '0'))
925
- .slice(0, 10)
926
- .map((s) => ({ venue: 'kalshi', ticker: s.ticker, title: s.title, volume: s.volume_fp }));
927
- // Polymarket: Gamma API search
928
- let polyMatched = [];
929
- try {
930
- const events = await (0, polymarket_js_1.polymarketSearch)(params.query, 10);
931
- for (const event of events) {
932
- for (const m of (event.markets || []).slice(0, 3)) {
933
- if (!m.active || m.closed)
934
- continue;
935
- const prices = (0, polymarket_js_1.parseOutcomePrices)(m.outcomePrices);
936
- polyMatched.push({
937
- venue: 'polymarket',
938
- id: m.conditionId || m.id,
939
- title: m.groupItemTitle ? `${event.title}: ${m.groupItemTitle}` : m.question || event.title,
940
- price: prices[0] ? Math.round(prices[0] * 100) : null,
941
- volume24h: m.volume24hr,
942
- liquidity: m.liquidityNum,
943
- });
944
- }
945
- }
946
- }
947
- catch { /* Polymarket search optional */ }
948
- return {
949
- content: [{ type: 'text', text: JSON.stringify({ kalshi: kalshiMatched, polymarket: polyMatched }, null, 2) }],
950
- details: {},
951
- };
952
- }
953
- return { content: [{ type: 'text', text: '{"error":"Provide query, series, or market parameter"}' }], details: {} };
954
- },
955
- },
956
- {
957
- name: 'list_theses',
958
- label: 'List Theses',
959
- description: 'List all theses for the current user',
960
- parameters: emptyParams,
961
- execute: async () => {
962
- const theses = await sfClient.listTheses();
963
- return {
964
- content: [{ type: 'text', text: JSON.stringify(theses, null, 2) }],
965
- details: {},
966
- };
967
- },
968
- },
969
- {
970
- name: 'get_positions',
971
- label: 'Get Positions',
972
- description: 'Get positions across Kalshi + Polymarket with live prices and PnL',
973
- parameters: emptyParams,
974
- execute: async () => {
975
- const result = { kalshi: [], polymarket: [] };
976
- // Kalshi positions
977
- const positions = await (0, kalshi_js_1.getPositions)();
978
- if (positions) {
979
- for (const pos of positions) {
980
- const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
981
- if (livePrice !== null) {
982
- pos.current_value = livePrice;
983
- pos.unrealized_pnl = Math.round((livePrice - pos.average_price_paid) * pos.quantity);
984
- }
985
- }
986
- cachedPositions = positions;
987
- result.kalshi = positions.map((p) => ({
988
- venue: 'kalshi',
989
- ticker: p.ticker,
990
- side: p.side,
991
- quantity: p.quantity,
992
- avg_price: `${p.average_price_paid}¢`,
993
- current_price: `${p.current_value}¢`,
994
- unrealized_pnl: `$${(p.unrealized_pnl / 100).toFixed(2)}`,
995
- total_cost: `$${(p.total_cost / 100).toFixed(2)}`,
996
- }));
997
- }
998
- // Polymarket positions
999
- const config = (0, config_js_1.loadConfig)();
1000
- if (config.polymarketWalletAddress) {
1001
- try {
1002
- const polyPos = await (0, polymarket_js_1.polymarketGetPositions)(config.polymarketWalletAddress);
1003
- result.polymarket = polyPos.map((p) => ({
1004
- venue: 'polymarket',
1005
- market: p.title || p.slug || p.asset,
1006
- side: p.outcome || 'Yes',
1007
- size: p.size,
1008
- avg_price: `${Math.round((p.avgPrice || 0) * 100)}¢`,
1009
- current_price: `${Math.round((p.curPrice || p.currentPrice || 0) * 100)}¢`,
1010
- pnl: `$${(p.cashPnl || 0).toFixed(2)}`,
1011
- }));
1012
- }
1013
- catch { /* skip */ }
1014
- }
1015
- if (result.kalshi.length === 0 && result.polymarket.length === 0) {
1016
- return {
1017
- content: [{ type: 'text', text: 'No positions found. Configure Kalshi (KALSHI_API_KEY_ID) or Polymarket (sf setup --polymarket) to see positions.' }],
1018
- details: {},
1019
- };
1020
- }
1021
- return {
1022
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1023
- details: {},
1024
- };
1025
- },
1026
- },
1027
- {
1028
- name: 'web_search',
1029
- label: 'Web Search',
1030
- description: 'Search latest news and information. Use for real-time info not yet covered by the causal tree or heartbeat engine.',
1031
- parameters: webSearchParams,
1032
- execute: async (_toolCallId, params) => {
1033
- const tavilyKey = process.env.TAVILY_API_KEY;
1034
- const canProxy = !tavilyKey && sfApiKey;
1035
- if (!tavilyKey && !canProxy) {
1036
- return {
1037
- content: [{ type: 'text', text: 'Web search not available. Run sf login (proxied search) or set TAVILY_API_KEY.' }],
1038
- details: {},
1039
- };
1040
- }
1041
- let res;
1042
- if (tavilyKey) {
1043
- // Direct mode
1044
- res = await fetch('https://api.tavily.com/search', {
1045
- method: 'POST',
1046
- headers: { 'Content-Type': 'application/json' },
1047
- body: JSON.stringify({
1048
- api_key: tavilyKey,
1049
- query: params.query,
1050
- max_results: 5,
1051
- search_depth: 'basic',
1052
- include_answer: true,
1053
- }),
1054
- });
1055
- }
1056
- else {
1057
- // Proxy mode
1058
- res = await fetch(`${sfApiUrl}/api/proxy/search`, {
1059
- method: 'POST',
1060
- headers: {
1061
- 'Content-Type': 'application/json',
1062
- 'Authorization': `Bearer ${sfApiKey}`,
1063
- },
1064
- body: JSON.stringify({
1065
- query: params.query,
1066
- max_results: 5,
1067
- search_depth: 'basic',
1068
- include_answer: true,
1069
- }),
1070
- });
1071
- }
1072
- if (!res.ok) {
1073
- return {
1074
- content: [{ type: 'text', text: `Search failed: ${res.status}` }],
1075
- details: {},
1076
- };
1077
- }
1078
- const data = await res.json();
1079
- const results = (data.results || []).map((r) => `[${r.title}](${r.url})\n${r.content?.slice(0, 200)}`).join('\n\n');
1080
- const answer = data.answer ? `Summary: ${data.answer}\n\n---\n\n` : '';
1081
- return {
1082
- content: [{ type: 'text', text: `${answer}${results}` }],
1083
- details: {},
1084
- };
1085
- },
1086
- },
1087
- {
1088
- name: 'explore_public',
1089
- label: 'Explore Public Theses',
1090
- description: 'Browse public theses from other users. No auth required. Pass a slug to get details, or omit to list all.',
1091
- parameters: Type.Object({
1092
- slug: Type.Optional(Type.String({ description: 'Specific thesis slug, or empty to list all' })),
1093
- }),
1094
- execute: async (_toolCallId, params) => {
1095
- const base = 'https://simplefunctions.dev';
1096
- if (params.slug) {
1097
- const res = await fetch(`${base}/api/public/thesis/${params.slug}`);
1098
- if (!res.ok)
1099
- return { content: [{ type: 'text', text: `Not found: ${params.slug}` }], details: {} };
1100
- const data = await res.json();
1101
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1102
- }
1103
- const res = await fetch(`${base}/api/public/theses`);
1104
- if (!res.ok)
1105
- return { content: [{ type: 'text', text: 'Failed to fetch public theses' }], details: {} };
1106
- const data = await res.json();
1107
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1108
- },
1109
- },
1110
- {
1111
- name: 'create_strategy',
1112
- label: 'Create Strategy',
1113
- description: 'Create a trading strategy for a thesis. Extract hard conditions (entryBelow/stopLoss/takeProfit as cents) and soft conditions from conversation. Called when user mentions specific trade ideas.',
1114
- parameters: Type.Object({
1115
- thesisId: Type.String({ description: 'Thesis ID' }),
1116
- marketId: Type.String({ description: 'Market ticker e.g. KXWTIMAX-26DEC31-T150' }),
1117
- market: Type.String({ description: 'Human-readable market name' }),
1118
- direction: Type.String({ description: 'yes or no' }),
1119
- horizon: Type.Optional(Type.String({ description: 'short, medium, or long. Default: medium' })),
1120
- entryBelow: Type.Optional(Type.Number({ description: 'Entry trigger: ask <= this value (cents)' })),
1121
- entryAbove: Type.Optional(Type.Number({ description: 'Entry trigger: ask >= this value (cents, for NO direction)' })),
1122
- stopLoss: Type.Optional(Type.Number({ description: 'Stop loss: bid <= this value (cents)' })),
1123
- takeProfit: Type.Optional(Type.Number({ description: 'Take profit: bid >= this value (cents)' })),
1124
- maxQuantity: Type.Optional(Type.Number({ description: 'Max total contracts. Default: 500' })),
1125
- perOrderQuantity: Type.Optional(Type.Number({ description: 'Contracts per order. Default: 50' })),
1126
- softConditions: Type.Optional(Type.String({ description: 'LLM-evaluated conditions e.g. "only enter when n3 > 60%"' })),
1127
- rationale: Type.Optional(Type.String({ description: 'Full logic description' })),
1128
- }),
1129
- execute: async (_toolCallId, params) => {
1130
- const result = await sfClient.createStrategyAPI(params.thesisId, {
1131
- marketId: params.marketId,
1132
- market: params.market,
1133
- direction: params.direction,
1134
- horizon: params.horizon,
1135
- entryBelow: params.entryBelow,
1136
- entryAbove: params.entryAbove,
1137
- stopLoss: params.stopLoss,
1138
- takeProfit: params.takeProfit,
1139
- maxQuantity: params.maxQuantity,
1140
- perOrderQuantity: params.perOrderQuantity,
1141
- softConditions: params.softConditions,
1142
- rationale: params.rationale,
1143
- createdBy: 'agent',
1144
- });
1145
- return { content: [{ type: 'text', text: JSON.stringify(result) }], details: {} };
1146
- },
1147
- },
1148
- {
1149
- name: 'list_strategies',
1150
- label: 'List Strategies',
1151
- description: 'List strategies for a thesis. Filter by status (active/watching/executed/cancelled/review) or omit for all.',
1152
- parameters: Type.Object({
1153
- thesisId: Type.String({ description: 'Thesis ID' }),
1154
- status: Type.Optional(Type.String({ description: 'Filter by status. Omit for all.' })),
1155
- }),
1156
- execute: async (_toolCallId, params) => {
1157
- const result = await sfClient.getStrategies(params.thesisId, params.status);
1158
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
1159
- },
1160
- },
1161
- {
1162
- name: 'update_strategy',
1163
- label: 'Update Strategy',
1164
- description: 'Update a strategy (change stop loss, take profit, status, priority, etc.)',
1165
- parameters: Type.Object({
1166
- thesisId: Type.String({ description: 'Thesis ID' }),
1167
- strategyId: Type.String({ description: 'Strategy ID (UUID)' }),
1168
- stopLoss: Type.Optional(Type.Number({ description: 'New stop loss (cents)' })),
1169
- takeProfit: Type.Optional(Type.Number({ description: 'New take profit (cents)' })),
1170
- entryBelow: Type.Optional(Type.Number({ description: 'New entry below trigger (cents)' })),
1171
- entryAbove: Type.Optional(Type.Number({ description: 'New entry above trigger (cents)' })),
1172
- status: Type.Optional(Type.String({ description: 'New status: active|watching|executed|cancelled|review' })),
1173
- priority: Type.Optional(Type.Number({ description: 'New priority' })),
1174
- softConditions: Type.Optional(Type.String({ description: 'Updated soft conditions' })),
1175
- rationale: Type.Optional(Type.String({ description: 'Updated rationale' })),
1176
- }),
1177
- execute: async (_toolCallId, params) => {
1178
- const { thesisId, strategyId, ...updates } = params;
1179
- const result = await sfClient.updateStrategyAPI(thesisId, strategyId, updates);
1180
- return { content: [{ type: 'text', text: JSON.stringify(result) }], details: {} };
1181
- },
1182
- },
1183
- {
1184
- name: 'get_milestones',
1185
- label: 'Milestones',
1186
- description: 'Get upcoming events from Kalshi calendar. Use to check economic releases, political events, or other catalysts coming up that might affect the thesis.',
1187
- parameters: Type.Object({
1188
- hours: Type.Optional(Type.Number({ description: 'Hours ahead to look (default 168 = 1 week)' })),
1189
- category: Type.Optional(Type.String({ description: 'Filter by category (e.g. Economics, Politics, Sports)' })),
1190
- }),
1191
- execute: async (_toolCallId, params) => {
1192
- const hours = params.hours || 168;
1193
- const now = new Date();
1194
- const url = `https://api.elections.kalshi.com/trade-api/v2/milestones?limit=200&minimum_start_date=${now.toISOString()}` +
1195
- (params.category ? `&category=${params.category}` : '');
1196
- const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
1197
- if (!res.ok)
1198
- return { content: [{ type: 'text', text: `Milestones API error: ${res.status}` }], details: {} };
1199
- const data = await res.json();
1200
- const cutoff = now.getTime() + hours * 3600000;
1201
- const filtered = (data.milestones || [])
1202
- .filter((m) => new Date(m.start_date).getTime() <= cutoff)
1203
- .slice(0, 30)
1204
- .map((m) => ({
1205
- title: m.title,
1206
- category: m.category,
1207
- start_date: m.start_date,
1208
- related_event_tickers: m.related_event_tickers,
1209
- hours_until: Math.round((new Date(m.start_date).getTime() - now.getTime()) / 3600000),
1210
- }));
1211
- return { content: [{ type: 'text', text: JSON.stringify(filtered, null, 2) }], details: {} };
1212
- },
1213
- },
1214
- {
1215
- name: 'get_forecast',
1216
- label: 'Forecast',
1217
- description: 'Get market distribution (P50/P75/P90 percentile history) for a Kalshi event. Shows how market consensus has shifted over time.',
1218
- parameters: Type.Object({
1219
- eventTicker: Type.String({ description: 'Kalshi event ticker (e.g. KXWTIMAX-26DEC31)' }),
1220
- days: Type.Optional(Type.Number({ description: 'Days of history (default 7)' })),
1221
- }),
1222
- execute: async (_toolCallId, params) => {
1223
- const { getForecastHistory } = await import('../kalshi.js');
1224
- const days = params.days || 7;
1225
- // Get series ticker from event
1226
- const evtRes = await fetch(`https://api.elections.kalshi.com/trade-api/v2/events/${params.eventTicker}`, { headers: { 'Accept': 'application/json' } });
1227
- if (!evtRes.ok)
1228
- return { content: [{ type: 'text', text: `Event not found: ${params.eventTicker}` }], details: {} };
1229
- const evtData = await evtRes.json();
1230
- const seriesTicker = evtData.event?.series_ticker;
1231
- if (!seriesTicker)
1232
- return { content: [{ type: 'text', text: `No series_ticker for ${params.eventTicker}` }], details: {} };
1233
- const history = await getForecastHistory({
1234
- seriesTicker,
1235
- eventTicker: params.eventTicker,
1236
- percentiles: [5000, 7500, 9000],
1237
- startTs: Math.floor((Date.now() - days * 86400000) / 1000),
1238
- endTs: Math.floor(Date.now() / 1000),
1239
- periodInterval: 1440,
1240
- });
1241
- if (!history || history.length === 0)
1242
- return { content: [{ type: 'text', text: 'No forecast data available' }], details: {} };
1243
- return { content: [{ type: 'text', text: JSON.stringify(history, null, 2) }], details: {} };
1244
- },
1245
- },
1246
- {
1247
- name: 'get_settlements',
1248
- label: 'Settlements',
1249
- description: 'Get settled (resolved) contracts with P&L. Shows which contracts won/lost and realized returns.',
1250
- parameters: Type.Object({
1251
- ticker: Type.Optional(Type.String({ description: 'Filter by market ticker' })),
1252
- }),
1253
- execute: async (_toolCallId, params) => {
1254
- const { getSettlements } = await import('../kalshi.js');
1255
- const result = await getSettlements({ limit: 100, ticker: params.ticker });
1256
- if (!result)
1257
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
1258
- return { content: [{ type: 'text', text: JSON.stringify(result.settlements, null, 2) }], details: {} };
1259
- },
1260
- },
1261
- {
1262
- name: 'get_balance',
1263
- label: 'Balance',
1264
- description: 'Get Kalshi account balance and portfolio value.',
1265
- parameters: emptyParams,
1266
- execute: async () => {
1267
- const { getBalance } = await import('../kalshi.js');
1268
- const result = await getBalance();
1269
- if (!result)
1270
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
1271
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
1272
- },
1273
- },
1274
- {
1275
- name: 'get_orders',
1276
- label: 'Orders',
1277
- description: 'Get current resting orders on Kalshi. Stale orders (>7 days old AND >10¢ from market) are flagged.',
1278
- parameters: Type.Object({
1279
- status: Type.Optional(Type.String({ description: 'Filter by status: resting, canceled, executed. Default: resting' })),
1280
- }),
1281
- execute: async (_toolCallId, params) => {
1282
- const { getOrders, getMarketPrice } = await import('../kalshi.js');
1283
- const result = await getOrders({ status: params.status || 'resting', limit: 100 });
1284
- if (!result)
1285
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], 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: {} };
1305
- },
1306
- },
1307
- {
1308
- name: 'get_fills',
1309
- label: 'Fills',
1310
- description: 'Get recent trade fills (executed trades) on Kalshi.',
1311
- parameters: Type.Object({
1312
- ticker: Type.Optional(Type.String({ description: 'Filter by market ticker' })),
1313
- }),
1314
- execute: async (_toolCallId, params) => {
1315
- const { getFills } = await import('../kalshi.js');
1316
- const result = await getFills({ ticker: params.ticker, limit: 50 });
1317
- if (!result)
1318
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
1319
- return { content: [{ type: 'text', text: JSON.stringify(result.fills, null, 2) }], details: {} };
1320
- },
1321
- },
1322
- {
1323
- name: 'get_liquidity',
1324
- label: 'Liquidity Scanner',
1325
- description: 'Scan orderbook liquidity for a topic across Kalshi + Polymarket. Returns spread, depth, liquidity scores. Topics: ' + Object.keys(topics_js_1.TOPIC_SERIES).join(', '),
1326
- parameters: Type.Object({
1327
- topic: Type.String({ description: 'Topic to scan (e.g. oil, crypto, fed, geopolitics)' }),
1328
- }),
1329
- execute: async (_toolCallId, params) => {
1330
- const topicKey = params.topic.toLowerCase();
1331
- const seriesList = topics_js_1.TOPIC_SERIES[topicKey];
1332
- if (!seriesList) {
1333
- return { content: [{ type: 'text', text: `Unknown topic "${params.topic}". Available: ${Object.keys(topics_js_1.TOPIC_SERIES).join(', ')}` }], details: {} };
1334
- }
1335
- const results = [];
1336
- for (const series of seriesList) {
1337
- try {
1338
- const url = `https://api.elections.kalshi.com/trade-api/v2/markets?series_ticker=${series}&status=open&limit=200`;
1339
- const res = await fetch(url, { headers: { Accept: 'application/json' } });
1340
- if (!res.ok)
1341
- continue;
1342
- const markets = (await res.json()).markets || [];
1343
- const obResults = await Promise.allSettled(markets.slice(0, 20).map((m) => (0, kalshi_js_1.getPublicOrderbook)(m.ticker).then(ob => ({ ticker: m.ticker, title: m.title, ob }))));
1344
- for (const r of obResults) {
1345
- if (r.status !== 'fulfilled' || !r.value.ob)
1346
- continue;
1347
- const { ticker, title, ob } = r.value;
1348
- const yes = (ob.yes_dollars || []).map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), qty: parseFloat(q) })).filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
1349
- const no = (ob.no_dollars || []).map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), qty: parseFloat(q) })).filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
1350
- const bestBid = yes[0]?.price || 0;
1351
- const bestAsk = no.length > 0 ? (100 - no[0].price) : 100;
1352
- const spread = bestAsk - bestBid;
1353
- const depth = yes.slice(0, 3).reduce((s, l) => s + l.qty, 0) + no.slice(0, 3).reduce((s, l) => s + l.qty, 0);
1354
- const liq = spread <= 2 && depth >= 500 ? 'high' : spread <= 5 && depth >= 100 ? 'medium' : 'low';
1355
- results.push({ venue: 'kalshi', ticker, title: (title || '').slice(0, 50), bestBid, bestAsk, spread, depth: Math.round(depth), liquidityScore: liq });
1356
- }
1357
- }
1358
- catch { /* skip */ }
1359
- }
1360
- try {
1361
- const events = await (0, polymarket_js_1.polymarketSearch)(topicKey, 5);
1362
- for (const event of events) {
1363
- for (const m of (event.markets || []).slice(0, 5)) {
1364
- if (!m.active || m.closed || !m.clobTokenIds)
1365
- continue;
1366
- const ids = (0, polymarket_js_1.parseClobTokenIds)(m.clobTokenIds);
1367
- if (!ids)
1368
- continue;
1369
- const d = await (0, polymarket_js_1.polymarketGetOrderbookWithDepth)(ids[0]);
1370
- if (!d)
1371
- continue;
1372
- results.push({ venue: 'polymarket', ticker: (m.question || event.title).slice(0, 50), bestBid: d.bestBid, bestAsk: d.bestAsk, spread: d.spread, depth: d.bidDepthTop3 + d.askDepthTop3, liquidityScore: d.liquidityScore });
1373
- }
1374
- }
1375
- }
1376
- catch { /* skip */ }
1377
- results.sort((a, b) => a.spread - b.spread);
1378
- return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], details: {} };
1379
- },
1380
- },
1381
- {
1382
- name: 'inspect_book',
1383
- label: 'Orderbook',
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.',
1385
- parameters: Type.Object({
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"])' })),
1388
- polyQuery: Type.Optional(Type.String({ description: 'Search Polymarket by keyword (e.g. "oil price above 100")' })),
1389
- }),
1390
- execute: async (_toolCallId, params) => {
1391
- const results = [];
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) {
1399
- try {
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
- }
1446
- }
1447
- catch (err) {
1448
- results.push({ venue: 'kalshi', ticker: tkr, status: 'api_error', reason: `Kalshi API error: ${err.message}` });
1449
- }
1450
- }
1451
- if (params.polyQuery) {
1452
- try {
1453
- const events = await (0, polymarket_js_1.polymarketSearch)(params.polyQuery, 5);
1454
- for (const event of events) {
1455
- for (const m of (event.markets || []).slice(0, 3)) {
1456
- if (!m.active || m.closed || !m.clobTokenIds)
1457
- continue;
1458
- const ids = (0, polymarket_js_1.parseClobTokenIds)(m.clobTokenIds);
1459
- if (!ids)
1460
- continue;
1461
- const depth = await (0, polymarket_js_1.polymarketGetOrderbookWithDepth)(ids[0]);
1462
- if (!depth)
1463
- continue;
1464
- const prices = (0, polymarket_js_1.parseOutcomePrices)(m.outcomePrices);
1465
- results.push({
1466
- venue: 'polymarket', title: m.question || event.title,
1467
- bestBid: depth.bestBid, bestAsk: depth.bestAsk, spread: depth.spread,
1468
- liquidityScore: depth.liquidityScore,
1469
- totalBidDepth: depth.totalBidDepth, totalAskDepth: depth.totalAskDepth,
1470
- lastPrice: prices[0] ? Math.round(prices[0] * 100) : 0,
1471
- volume24h: m.volume24hr || 0,
1472
- });
1473
- }
1474
- }
1475
- }
1476
- catch { /* skip */ }
1477
- }
1478
- if (results.length === 0) {
1479
- return { content: [{ type: 'text', text: 'No markets found. Provide ticker (Kalshi) or polyQuery (Polymarket search).' }], details: {} };
1480
- }
1481
- return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], details: {} };
1482
- },
1483
- },
1484
- {
1485
- name: 'get_schedule',
1486
- label: 'Schedule',
1487
- description: 'Get exchange status (open/closed) and trading hours. Use to check if low liquidity is due to off-hours.',
1488
- parameters: emptyParams,
1489
- execute: async () => {
1490
- try {
1491
- const res = await fetch('https://api.elections.kalshi.com/trade-api/v2/exchange/status', { headers: { 'Accept': 'application/json' } });
1492
- if (!res.ok)
1493
- return { content: [{ type: 'text', text: `Exchange API error: ${res.status}` }], details: {} };
1494
- const data = await res.json();
1495
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1496
- }
1497
- catch (err) {
1498
- return { content: [{ type: 'text', text: `Failed: ${err.message}` }], details: {} };
1499
- }
1500
- },
1501
- },
1502
- {
1503
- name: 'create_thesis',
1504
- label: 'Create Thesis',
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.',
1506
- parameters: Type.Object({
1507
- rawThesis: Type.String({ description: 'The raw thesis statement to create' }),
1508
- webhookUrl: Type.Optional(Type.String({ description: 'Optional webhook URL for notifications' })),
1509
- }),
1510
- execute: async (_toolCallId, params) => {
1511
- const result = await sfClient.createThesis(params.rawThesis, true);
1512
- const thesis = result.thesis || result;
1513
- const nodeCount = thesis.causalTree?.nodes?.length || 0;
1514
- const edgeCount = (thesis.edges || []).length;
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
- }
1529
- return {
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.` }],
1531
- details: {},
1532
- };
1533
- },
1534
- },
1535
- {
1536
- name: 'get_edges',
1537
- label: 'Get Edges',
1538
- description: 'Get top edges across all active theses. Returns the top 10 edges sorted by absolute edge size with ticker, market name, edge size, direction, and venue.',
1539
- parameters: emptyParams,
1540
- execute: async () => {
1541
- const { theses } = await sfClient.listTheses();
1542
- const activeTheses = (theses || []).filter((t) => t.status === 'active' || t.status === 'monitoring');
1543
- const results = await Promise.allSettled(activeTheses.map(async (t) => {
1544
- const ctx = await sfClient.getContext(t.id);
1545
- return (ctx.edges || []).map((e) => ({ ...e, thesisId: t.id }));
1546
- }));
1547
- const allEdges = [];
1548
- for (const r of results) {
1549
- if (r.status === 'fulfilled')
1550
- allEdges.push(...r.value);
1551
- }
1552
- allEdges.sort((a, b) => Math.abs(b.edge || b.edgeSize || 0) - Math.abs(a.edge || a.edgeSize || 0));
1553
- const top10 = allEdges.slice(0, 10).map((e) => ({
1554
- ticker: e.marketId || e.ticker || '-',
1555
- market: e.market || e.marketTitle || '-',
1556
- edge: e.edge || e.edgeSize || 0,
1557
- direction: e.direction || 'yes',
1558
- venue: e.venue || 'kalshi',
1559
- }));
1560
- return {
1561
- content: [{ type: 'text', text: JSON.stringify(top10, null, 2) }],
1562
- details: {},
1563
- };
1564
- },
1565
- },
1566
- {
1567
- name: 'get_feed',
1568
- label: 'Get Feed',
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.',
1570
- parameters: Type.Object({
1571
- hours: Type.Optional(Type.Number({ description: 'Hours of history to fetch (default 24)' })),
1572
- }),
1573
- execute: async (_toolCallId, params) => {
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;
1603
- return {
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) }],
1627
- details: {},
1628
- };
1629
- },
1630
- },
1631
- ];
1632
- // ── What-if tool (always available) ────────────────────────────────────────
1633
- tools.push({
1634
- name: 'what_if',
1635
- label: 'What-If',
1636
- description: 'Run a what-if scenario: override causal tree node probabilities and see how edges and confidence change. Zero LLM cost — pure computation. Use when user asks "what if X happens?" or "what if this node drops to Y%?".',
1637
- parameters: Type.Object({
1638
- overrides: Type.Array(Type.Object({
1639
- nodeId: Type.String({ description: 'Causal tree node ID (e.g. n1, n3.1)' }),
1640
- newProbability: Type.Number({ description: 'New probability 0-1' }),
1641
- }), { description: 'Node probability overrides' }),
1642
- }),
1643
- execute: async (_toolCallId, params) => {
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
- }
1651
- const ctx = latestContext;
1652
- const allNodes = [];
1653
- function flatten(nodes) {
1654
- for (const n of nodes) {
1655
- allNodes.push(n);
1656
- if (n.children?.length)
1657
- flatten(n.children);
1658
- }
1659
- }
1660
- const rawNodes = ctx.causalTree?.nodes || [];
1661
- flatten(rawNodes);
1662
- const treeNodes = rawNodes.filter((n) => n.depth === 0 || (n.depth === undefined && !n.id.includes('.')));
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
- }
1683
- const oldConf = treeNodes.reduce((s, n) => s + (n.probability || 0) * (n.importance || 0), 0);
1684
- const newConf = treeNodes.reduce((s, n) => {
1685
- return s + effectiveProb(n) * (n.importance || 0);
1686
- }, 0);
1687
- const nodeScales = new Map();
1688
- for (const [nid, np] of overrideMap.entries()) {
1689
- const nd = allNodes.find((n) => n.id === nid);
1690
- if (nd && nd.probability > 0)
1691
- nodeScales.set(nid, Math.max(0, Math.min(2, np / nd.probability)));
1692
- }
1693
- const edges = (ctx.edges || []).map((edge) => {
1694
- const relNode = edge.relatedNodeId;
1695
- let scaleFactor = 1;
1696
- if (relNode) {
1697
- const candidates = [relNode, relNode.split('.').slice(0, -1).join('.'), relNode.split('.')[0]].filter(Boolean);
1698
- for (const cid of candidates) {
1699
- if (nodeScales.has(cid)) {
1700
- scaleFactor = nodeScales.get(cid);
1701
- break;
1702
- }
1703
- }
1704
- }
1705
- const mkt = edge.marketPrice || 0;
1706
- const oldTP = edge.thesisPrice || edge.thesisImpliedPrice || mkt;
1707
- const oldEdge = edge.edge || edge.edgeSize || 0;
1708
- const newTP = Math.round((mkt + (oldTP - mkt) * scaleFactor) * 100) / 100;
1709
- const dir = edge.direction || 'yes';
1710
- const newEdge = Math.round((dir === 'yes' ? newTP - mkt : mkt - newTP) * 100) / 100;
1711
- return {
1712
- market: edge.market || edge.marketTitle || edge.marketId,
1713
- marketPrice: mkt,
1714
- oldEdge,
1715
- newEdge,
1716
- delta: newEdge - oldEdge,
1717
- signal: Math.abs(newEdge - oldEdge) < 1 ? 'unchanged' : (oldEdge > 0 && newEdge < 0) || (oldEdge < 0 && newEdge > 0) ? 'REVERSED' : Math.abs(newEdge) < 2 ? 'GONE' : 'reduced',
1718
- };
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;
1724
- const result = {
1725
- overrides: params.overrides.map((o) => {
1726
- const node = allNodes.find((n) => n.id === o.nodeId);
1727
- return { nodeId: o.nodeId, label: node?.label || o.nodeId, oldProb: node?.probability, newProb: o.newProbability };
1728
- }),
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,
1734
- affectedEdges: edges,
1735
- };
1736
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
1737
- },
1738
- });
1739
- // ── X (Twitter) tools ─────────────────────────────────────────────────────
1740
- tools.push({
1741
- name: 'search_x',
1742
- label: 'X Search',
1743
- description: 'Search X (Twitter) for recent discussions on a topic. Returns top posts with engagement metrics, sentiment analysis, and key themes. Use for social signal research on any prediction market topic.',
1744
- parameters: Type.Object({
1745
- query: Type.String({ description: 'Search query (e.g. "iran oil", "fed rate cut", "$BTC")' }),
1746
- mode: Type.Optional(Type.String({ description: '"summary" (default, with AI analysis) or "raw" (just posts)' })),
1747
- hours: Type.Optional(Type.Number({ description: 'Hours of history (default 24)' })),
1748
- }),
1749
- execute: async (_toolCallId, params) => {
1750
- const data = await sfClient.searchX(params.query, { mode: params.mode, hours: params.hours });
1751
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1752
- },
1753
- }, {
1754
- name: 'x_volume',
1755
- label: 'X Volume',
1756
- description: 'Get X discussion volume trend for a topic — total posts, velocity change vs prior period, peak time, and hourly timeseries. Use to detect social momentum shifts.',
1757
- parameters: Type.Object({
1758
- query: Type.String({ description: 'Search query' }),
1759
- hours: Type.Optional(Type.Number({ description: 'Hours of history (default 72)' })),
1760
- }),
1761
- execute: async (_toolCallId, params) => {
1762
- const data = await sfClient.getXVolume(params.query, { hours: params.hours });
1763
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1764
- },
1765
- }, {
1766
- name: 'x_news',
1767
- label: 'X News',
1768
- description: 'Get news stories trending on X — titles, summaries, categories, and ticker mentions. Use for breaking news and narrative tracking.',
1769
- parameters: Type.Object({
1770
- query: Type.String({ description: 'Search query' }),
1771
- limit: Type.Optional(Type.Number({ description: 'Max stories (default 10)' })),
1772
- }),
1773
- execute: async (_toolCallId, params) => {
1774
- const data = await sfClient.searchXNews(params.query, { limit: params.limit });
1775
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1776
- },
1777
- }, {
1778
- name: 'x_account',
1779
- label: 'X Account',
1780
- description: 'Get recent posts from a specific X account. Use to track key opinion leaders, officials, or analysts.',
1781
- parameters: Type.Object({
1782
- username: Type.String({ description: 'X username (with or without @)' }),
1783
- hours: Type.Optional(Type.Number({ description: 'Hours of history (default 24)' })),
1784
- }),
1785
- execute: async (_toolCallId, params) => {
1786
- const data = await sfClient.getXAccount(params.username, { hours: params.hours });
1787
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1788
- },
1789
- }, {
1790
- name: 'heartbeat_config',
1791
- label: 'Heartbeat Config',
1792
- description: 'View or update heartbeat settings for a thesis: scan intervals, model tier, budget cap, pause/resume. Also shows this month\'s cost breakdown.',
1793
- parameters: Type.Object({
1794
- thesisId: Type.String({ description: 'Thesis ID' }),
1795
- newsIntervalMin: Type.Optional(Type.Number({ description: 'News scan interval in minutes (15-1440)' })),
1796
- xIntervalMin: Type.Optional(Type.Number({ description: 'X scan interval in minutes (60-1440)' })),
1797
- evalModelTier: Type.Optional(Type.String({ description: 'Eval model: cheap, medium, or heavy' })),
1798
- monthlyBudgetUsd: Type.Optional(Type.Number({ description: 'Monthly budget cap in USD (0 = unlimited)' })),
1799
- paused: Type.Optional(Type.Boolean({ description: 'Pause (true) or resume (false) heartbeat' })),
1800
- }),
1801
- execute: async (_toolCallId, params) => {
1802
- const hasUpdates = params.newsIntervalMin || params.xIntervalMin || params.evalModelTier || params.monthlyBudgetUsd !== undefined || params.paused !== undefined;
1803
- if (hasUpdates) {
1804
- const updates = {};
1805
- if (params.newsIntervalMin)
1806
- updates.newsIntervalMin = params.newsIntervalMin;
1807
- if (params.xIntervalMin)
1808
- updates.xIntervalMin = params.xIntervalMin;
1809
- if (params.evalModelTier)
1810
- updates.evalModelTier = params.evalModelTier;
1811
- if (params.monthlyBudgetUsd !== undefined)
1812
- updates.monthlyBudgetUsd = params.monthlyBudgetUsd;
1813
- if (params.paused !== undefined)
1814
- updates.paused = params.paused;
1815
- await sfClient.updateHeartbeatConfig(params.thesisId, updates);
1816
- }
1817
- const data = await sfClient.getHeartbeatConfig(params.thesisId);
1818
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
1819
- },
1820
- });
1821
- // ── Trading tools (conditional on tradingEnabled) ──────────────────────────
1822
- const config = (0, config_js_1.loadConfig)();
1823
- if (config.tradingEnabled) {
1824
- tools.push({
1825
- name: 'place_order',
1826
- label: 'Place Order',
1827
- description: 'Place a buy or sell order on Kalshi. Shows a preview and asks for user confirmation before executing. Use for limit or market orders.',
1828
- parameters: Type.Object({
1829
- ticker: Type.String({ description: 'Market ticker e.g. KXWTIMAX-26DEC31-T135' }),
1830
- side: Type.String({ description: 'yes or no' }),
1831
- action: Type.String({ description: 'buy or sell' }),
1832
- type: Type.String({ description: 'limit or market' }),
1833
- count: Type.Number({ description: 'Number of contracts' }),
1834
- price_cents: Type.Optional(Type.Number({ description: 'Limit price in cents (1-99). Required for limit orders.' })),
1835
- }),
1836
- execute: async (_toolCallId, params) => {
1837
- const { createOrder } = await import('../kalshi.js');
1838
- const priceCents = params.price_cents ? Math.round(Number(params.price_cents)) : undefined;
1839
- const maxCost = ((priceCents || 99) * params.count / 100).toFixed(2);
1840
- // Show preview
1841
- const preview = [
1842
- C.zinc200(bold('ORDER PREVIEW')),
1843
- ` Ticker: ${params.ticker}`,
1844
- ` Side: ${params.side === 'yes' ? C.emerald('YES') : C.red('NO')}`,
1845
- ` Action: ${params.action.toUpperCase()}`,
1846
- ` Quantity: ${params.count}`,
1847
- ` Type: ${params.type}`,
1848
- priceCents ? ` Price: ${priceCents}\u00A2` : '',
1849
- ` Max cost: $${maxCost}`,
1850
- ].filter(Boolean).join('\n');
1851
- addSystemText(preview);
1852
- addSpacer();
1853
- tui.requestRender();
1854
- // Ask for confirmation via promptUser
1855
- const answer = await promptUser('Execute this order? (y/n)');
1856
- if (!answer.toLowerCase().startsWith('y')) {
1857
- return { content: [{ type: 'text', text: 'Order cancelled by user.' }], details: {} };
1858
- }
1859
- try {
1860
- const result = await createOrder({
1861
- ticker: params.ticker,
1862
- side: params.side,
1863
- action: params.action,
1864
- type: params.type,
1865
- count: params.count,
1866
- ...(priceCents ? { yes_price: priceCents } : {}),
1867
- });
1868
- const order = result.order || result;
1869
- return {
1870
- content: [{ type: 'text', text: `Order placed: ${order.order_id || 'OK'}\nStatus: ${order.status || '-'}\nFilled: ${order.fill_count_fp || 0}/${order.initial_count_fp || params.count}` }],
1871
- details: {},
1872
- };
1873
- }
1874
- catch (err) {
1875
- const msg = err.message || String(err);
1876
- if (msg.includes('403')) {
1877
- return { content: [{ type: 'text', text: '403 Forbidden \u2014 your Kalshi key lacks write permission. Get a read+write key at kalshi.com/account/api-keys' }], details: {} };
1878
- }
1879
- return { content: [{ type: 'text', text: `Order failed: ${msg}` }], details: {} };
1880
- }
1881
- },
1882
- }, {
1883
- name: 'cancel_order',
1884
- label: 'Cancel Order',
1885
- description: 'Cancel a resting order by order ID.',
1886
- parameters: Type.Object({
1887
- order_id: Type.String({ description: 'Order ID to cancel' }),
1888
- }),
1889
- execute: async (_toolCallId, params) => {
1890
- const { cancelOrder } = await import('../kalshi.js');
1891
- const answer = await promptUser(`Cancel order ${params.order_id}? (y/n)`);
1892
- if (!answer.toLowerCase().startsWith('y')) {
1893
- return { content: [{ type: 'text', text: 'Cancel aborted by user.' }], details: {} };
1894
- }
1895
- try {
1896
- await cancelOrder(params.order_id);
1897
- return { content: [{ type: 'text', text: `Order ${params.order_id} cancelled.` }], details: {} };
1898
- }
1899
- catch (err) {
1900
- return { content: [{ type: 'text', text: `Cancel failed: ${err.message}` }], details: {} };
1901
- }
1902
- },
1903
- });
1904
- }
1905
- // ── System prompt builder ──────────────────────────────────────────────────
1906
- function buildSystemPrompt(ctx) {
1907
- const edgesSummary = ctx.edges
1908
- ?.sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge))
1909
- .slice(0, 5)
1910
- .map((e) => ` ${(e.market || '').slice(0, 40)} | ${e.venue || 'kalshi'} | mkt ${e.marketPrice}\u00A2 \u2192 thesis ${e.thesisPrice}\u00A2 | edge ${e.edge > 0 ? '+' : ''}${e.edge} | ${e.orderbook?.liquidityScore || '?'}`)
1911
- .join('\n') || ' (no edge data)';
1912
- const nodesSummary = ctx.causalTree?.nodes
1913
- ?.filter((n) => n.depth === 0)
1914
- .map((n) => ` ${n.id} ${(n.label || '').slice(0, 40)} \u2014 ${Math.round(n.probability * 100)}%`)
1915
- .join('\n') || ' (no causal tree)';
1916
- const conf = typeof ctx.confidence === 'number'
1917
- ? Math.round(ctx.confidence * 100)
1918
- : (typeof ctx.confidence === 'string' ? parseInt(ctx.confidence) : 0);
1919
- return `You are a prediction market trading assistant. Your job is not to please the user \u2014 it is to help them see reality clearly and make correct trading decisions.
1920
-
1921
- ## Your analytical framework
1922
-
1923
- Each thesis has a causal tree. Every node is a causal hypothesis with a probability. Nodes have causal relationships \u2014 when upstream nodes change, downstream nodes follow.
1924
-
1925
- Edge = thesis-implied price - actual market price. Positive edge = market underprices. Contracts with large edges AND good liquidity are most tradeable.
1926
-
1927
- executableEdge = real edge after subtracting bid-ask spread. Big theoretical edge with wide spread may not be worth entering.
1928
-
1929
- ### Edge diagnosis (always classify)
1930
- When reporting an edge, classify it:
1931
- - "consensus gap": depth >= 500, market actively disagrees — real edge, real opponents
1932
- - "attention gap": depth < 100, no real pricing — edge may be illusory, needs catalyst
1933
- - "timing gap": market directionally agrees but lags — may close suddenly on news
1934
- - "risk premium": edge reflects settlement ambiguity or platform risk, not alpha
1935
- For edges > 20 cents, state in one sentence what must be true for the market to be right and the thesis wrong.
1936
-
1937
- ### Price reliability
1938
- 47 cents with depth 14K = strong consensus. 47 cents with depth 50 = three people's opinion.
1939
- - depth >= 500: reliable, treat as market consensus
1940
- - depth 100-500: moderate confidence, caveat when citing
1941
- - depth < 100: unreliable — do NOT treat as "the market thinks X"
1942
- - spread > 5 cents: wide disagreement, noisy midpoint
1943
- - liquidityScore = low: do NOT recommend entry
1944
-
1945
- ### Kill condition awareness
1946
- Each top-level causal node has an implicit falsifier. When scanning news or evaluating events, check: "Does any event here fundamentally break a core assumption?" If yes, flag immediately — this overrides all other analysis.
1947
-
1948
- ### Catalyst and time
1949
- When discussing an edge, always state contract expiry and next identifiable catalyst. No visible catalyst = flag capital lock risk.
1950
-
1951
- ### Research workflow
1952
- For complex questions, chain multiple tool calls:
1953
- 1. get_context 2. inspect_book 3. get_liquidity 4. web_search 5. search_x 6. synthesize
1954
- Don't answer a complex question with a single tool call.
1955
-
1956
- ### Social signal research
1957
- 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.
1958
-
1959
- ### Heartbeat config
1960
- 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.
1961
-
1962
- ### Conditional rules
1963
- - Portfolio/positions questions: flag correlated exposure — positions sharing upstream causal nodes are not independent bets.
1964
- - No catalyst visible within 30 days + edge not improving: flag "stale capital risk."
1965
- - Edges < 10 cents, thin liquidity, no catalyst near: say "nothing to do right now." Do not manufacture urgency.
1966
-
1967
- Short-term markets settle into hard data that calibrates the thesis. Use them to verify node probabilities, not to bet.
1968
-
1969
- ## Your behavioral rules
1970
-
1971
- - IMPORTANT: You do NOT know the user's current positions at conversation start. Before discussing trades, recommending entries/exits, or analyzing portfolio risk, call get_positions FIRST. Never assume the user has no positions — they may have large existing holdings you don't know about.
1972
- - Think before calling tools. If the data is already in context, don't re-fetch.
1973
- - If the user says "note this" or mentions a news event, inject a signal. Don't ask "should I note this?"
1974
- - If the user says "evaluate" or "run it", trigger immediately. Don't confirm.
1975
- - Don't end every response with "anything else?" \u2014 the user will ask when they want to.
1976
- - If the user asks about latest news or real-time events, use web_search first, then answer based on results. If you find important information, suggest injecting it as a signal.
1977
- - If you notice an edge narrowing or disappearing, say so proactively. Don't only report good news.
1978
- - If a causal tree node probability seriously contradicts the market price, point it out.
1979
- - Use Chinese if the user writes in Chinese, English if they write in English.
1980
- - For any question about prices, positions, or P&L, ALWAYS call a tool to get fresh data first. Never answer price-related questions using the cached data in this system prompt.
1981
- - Prices are in cents (e.g. 35¢). P&L, cost, and balance are in dollars (e.g. $90.66). Tool outputs are pre-formatted with units — do not re-convert.
1982
- - Align tables. Be precise with numbers to the cent.
1983
-
1984
- ## Strategy rules
1985
-
1986
- When the conversation produces a concrete trade idea (specific contract, direction, price conditions), use create_strategy to record it immediately. Don't wait for the user to say "record this."
1987
- - Extract hard conditions (specific prices in cents) into entryBelow/stopLoss/takeProfit.
1988
- - Put fuzzy conditions into softConditions (e.g. "only if n3 > 60%", "spread < 3¢").
1989
- - Put the full reasoning into rationale.
1990
- - After creating, confirm the strategy details.
1991
- - If the user says "change the stop loss on T150 to 30", use update_strategy.
1992
-
1993
- ## Trading status
1994
-
1995
- ${config.tradingEnabled ? 'Trading is ENABLED. You have place_order and cancel_order tools.' : 'Trading is DISABLED. You cannot place or cancel orders. Tell the user to run `sf setup --enable-trading` to unlock trading.'}
1996
-
1997
- ## Current thesis state
1998
-
1999
- Thesis: ${ctx.thesis || ctx.rawThesis || 'N/A'}
2000
- ID: ${ctx.thesisId || resolvedThesisId}
2001
- Confidence: ${conf}%
2002
- Status: ${ctx.status}
2003
-
2004
- Top-level causal tree nodes:
2005
- ${nodesSummary}
2006
-
2007
- Top 5 edges by magnitude:
2008
- ${edgesSummary}
2009
-
2010
- ${ctx.lastEvaluation?.summary ? `Latest evaluation summary: ${ctx.lastEvaluation.summary.slice(0, 300)}` : ''}`;
2011
- }
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);
2064
- // ── Create Agent ───────────────────────────────────────────────────────────
2065
- const agent = new Agent({
2066
- initialState: {
2067
- systemPrompt,
2068
- model,
2069
- tools,
2070
- thinkingLevel: 'off',
2071
- },
2072
- streamFn: streamSimple,
2073
- getApiKey: (provider) => {
2074
- if (provider === 'openrouter')
2075
- return openrouterKey;
2076
- return undefined;
2077
- },
2078
- });
2079
- // ── Session restore ────────────────────────────────────────────────────────
2080
- let sessionRestored = false;
2081
- if (!opts?.newSession) {
2082
- const saved = loadSession(resolvedThesisId || '_explorer');
2083
- if (saved?.messages?.length > 0) {
2084
- try {
2085
- // Clean corrupted messages: empty content, missing role, broken alternation
2086
- const filtered = saved.messages.filter((m) => {
2087
- if (!m.role)
2088
- return false;
2089
- if (Array.isArray(m.content) && m.content.length === 0)
2090
- return false;
2091
- if (m.role === 'assistant' && !m.content && !m.tool_calls?.length)
2092
- return false;
2093
- return true;
2094
- });
2095
- // Fix alternation: ensure user → assistant → user → assistant
2096
- // Drop consecutive messages of the same role (keep first)
2097
- const cleaned = [];
2098
- for (const m of filtered) {
2099
- const lastRole = cleaned.length > 0 ? cleaned[cleaned.length - 1].role : null;
2100
- if (m.role === lastRole && m.role !== 'tool') {
2101
- // Skip consecutive same-role (except tool messages which can follow each other)
2102
- continue;
2103
- }
2104
- cleaned.push(m);
2105
- }
2106
- // Ensure conversation doesn't end with user message (API needs assistant reply)
2107
- if (cleaned.length > 0 && cleaned[cleaned.length - 1].role === 'user') {
2108
- cleaned.pop();
2109
- }
2110
- agent.replaceMessages(cleaned);
2111
- // Always update system prompt with fresh context
2112
- agent.setSystemPrompt(systemPrompt);
2113
- sessionRestored = true;
2114
- }
2115
- catch { /* corrupt session, start fresh */ }
2116
- }
2117
- }
2118
- // Helper to persist session after each turn
2119
- function persistSession() {
2120
- try {
2121
- const msgs = agent.state.messages;
2122
- if (msgs.length > 0) {
2123
- saveSession(resolvedThesisId || '_explorer', currentModelName, msgs);
2124
- }
2125
- }
2126
- catch { /* best-effort save */ }
2127
- }
2128
- // ── Subscribe to agent events → update TUI ────────────────────────────────
2129
- let currentAssistantMd = null;
2130
- let currentAssistantText = '';
2131
- let currentLoader = null;
2132
- const toolStartTimes = new Map();
2133
- const toolLines = new Map();
2134
- // Throttle renders during streaming to prevent flicker (max ~15fps)
2135
- let renderTimer = null;
2136
- function throttledRender() {
2137
- if (renderTimer)
2138
- return;
2139
- renderTimer = setTimeout(() => {
2140
- renderTimer = null;
2141
- tui.requestRender();
2142
- }, 66);
2143
- }
2144
- function flushRender() {
2145
- if (renderTimer) {
2146
- clearTimeout(renderTimer);
2147
- renderTimer = null;
2148
- }
2149
- tui.requestRender();
2150
- }
2151
- agent.subscribe((event) => {
2152
- if (event.type === 'message_start') {
2153
- // Show loader while waiting for first text
2154
- currentAssistantText = '';
2155
- currentAssistantMd = null;
2156
- currentLoader = new Loader(tui, (s) => C.emerald(s), (s) => C.zinc600(s), 'thinking...');
2157
- currentLoader.start();
2158
- chatContainer.addChild(currentLoader);
2159
- tui.requestRender();
2160
- }
2161
- if (event.type === 'message_update') {
2162
- const e = event.assistantMessageEvent;
2163
- if (e.type === 'text_delta') {
2164
- // Remove loader on first text delta
2165
- if (currentLoader) {
2166
- currentLoader.stop();
2167
- chatContainer.removeChild(currentLoader);
2168
- currentLoader = null;
2169
- // Create markdown component for assistant response
2170
- currentAssistantMd = new Markdown('', 1, 0, mdTheme, mdDefaultStyle);
2171
- chatContainer.addChild(currentAssistantMd);
2172
- }
2173
- currentAssistantText += e.delta;
2174
- if (currentAssistantMd) {
2175
- currentAssistantMd.setText(currentAssistantText);
2176
- }
2177
- // Throttled render to prevent flicker during fast token streaming
2178
- throttledRender();
2179
- }
2180
- }
2181
- if (event.type === 'message_end') {
2182
- // Clean up loader if still present (no text was generated)
2183
- if (currentLoader) {
2184
- currentLoader.stop();
2185
- chatContainer.removeChild(currentLoader);
2186
- currentLoader = null;
2187
- }
2188
- // Final render of the complete message
2189
- if (currentAssistantMd && currentAssistantText) {
2190
- currentAssistantMd.setText(currentAssistantText);
2191
- }
2192
- addSpacer();
2193
- currentAssistantMd = null;
2194
- currentAssistantText = '';
2195
- flushRender();
2196
- }
2197
- if (event.type === 'agent_end') {
2198
- // Agent turn fully complete — safe to accept new input
2199
- isProcessing = false;
2200
- persistSession();
2201
- flushRender();
2202
- // Deliver queued heartbeat notification if any
2203
- if (pendingHeartbeatDelta) {
2204
- const delta = pendingHeartbeatDelta;
2205
- pendingHeartbeatDelta = null;
2206
- handleHeartbeatDelta(delta);
2207
- }
2208
- }
2209
- if (event.type === 'tool_execution_start') {
2210
- const toolLine = new MutableLine(C.zinc600(` \u25B8 ${event.toolName}...`));
2211
- toolStartTimes.set(event.toolCallId || event.toolName, Date.now());
2212
- toolLines.set(event.toolCallId || event.toolName, toolLine);
2213
- chatContainer.addChild(toolLine);
2214
- totalToolCalls++;
2215
- footerBar.toolCount = totalToolCalls;
2216
- footerBar.update();
2217
- tui.requestRender();
2218
- }
2219
- if (event.type === 'tool_execution_end') {
2220
- const key = event.toolCallId || event.toolName;
2221
- const startTime = toolStartTimes.get(key);
2222
- const elapsed = startTime ? ((Date.now() - startTime) / 1000).toFixed(1) : '?';
2223
- const line = toolLines.get(key);
2224
- if (line) {
2225
- if (event.isError) {
2226
- line.setText(C.red(` \u2717 ${event.toolName} (${elapsed}s) error`));
2227
- }
2228
- else {
2229
- line.setText(C.zinc600(` \u25B8 ${event.toolName} `) + C.emerald(`\u2713`) + C.zinc600(` ${elapsed}s`));
2230
- }
2231
- }
2232
- toolStartTimes.delete(key);
2233
- toolLines.delete(key);
2234
- tui.requestRender();
2235
- }
2236
- });
2237
- // ── Slash command handlers ─────────────────────────────────────────────────
2238
- async function handleSlashCommand(cmd) {
2239
- const parts = cmd.trim().split(/\s+/);
2240
- const command = parts[0].toLowerCase();
2241
- switch (command) {
2242
- case '/help': {
2243
- addSpacer();
2244
- addSystemText(C.zinc200(bold('Commands')) + '\n' +
2245
- C.emerald('/help ') + C.zinc400('Show this help') + '\n' +
2246
- C.emerald('/tree ') + C.zinc400('Display causal tree') + '\n' +
2247
- C.emerald('/edges ') + C.zinc400('Display edge/spread table') + '\n' +
2248
- C.emerald('/pos ') + C.zinc400('Display Kalshi positions') + '\n' +
2249
- C.emerald('/eval ') + C.zinc400('Trigger deep evaluation') + '\n' +
2250
- C.emerald('/switch <id>') + C.zinc400(' Switch thesis') + '\n' +
2251
- C.emerald('/compact ') + C.zinc400('Compress conversation history') + '\n' +
2252
- C.emerald('/new ') + C.zinc400('Start fresh session') + '\n' +
2253
- C.emerald('/model <m> ') + C.zinc400('Switch model') + '\n' +
2254
- C.emerald('/env ') + C.zinc400('Show environment variable status') + '\n' +
2255
- (config.tradingEnabled ? (C.zinc600('\u2500'.repeat(30)) + '\n' +
2256
- C.emerald('/buy ') + C.zinc400('TICKER QTY PRICE \u2014 quick buy') + '\n' +
2257
- C.emerald('/sell ') + C.zinc400('TICKER QTY PRICE \u2014 quick sell') + '\n' +
2258
- C.emerald('/cancel ') + C.zinc400('ORDER_ID \u2014 cancel order') + '\n' +
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') : '') +
2264
- C.emerald('/clear ') + C.zinc400('Clear screen (keeps history)') + '\n' +
2265
- C.emerald('/exit ') + C.zinc400('Exit (auto-saves)'));
2266
- addSpacer();
2267
- return true;
2268
- }
2269
- case '/tree': {
2270
- addSpacer();
2271
- if (explorerMode) {
2272
- addSystemText(C.zinc400('No thesis selected. Use /switch <id> to pick one, or ask me to create one.'));
2273
- }
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
- }
2282
- }
2283
- addSpacer();
2284
- return true;
2285
- }
2286
- case '/edges': {
2287
- addSpacer();
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}`));
2310
- }
2311
- }
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
- }
2323
- }
2324
- addSpacer();
2325
- return true;
2326
- }
2327
- case '/pos': {
2328
- addSpacer();
2329
- try {
2330
- const positions = await (0, kalshi_js_1.getPositions)();
2331
- if (!positions) {
2332
- addSystemText(C.zinc600('Kalshi not configured'));
2333
- return true;
2334
- }
2335
- for (const pos of positions) {
2336
- const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
2337
- if (livePrice !== null) {
2338
- pos.current_value = livePrice;
2339
- pos.unrealized_pnl = Math.round((livePrice - pos.average_price_paid) * pos.quantity);
2340
- }
2341
- }
2342
- cachedPositions = positions;
2343
- addSystemText(C.zinc200(bold('Positions')) + '\n' + renderPositions(positions));
2344
- }
2345
- catch (err) {
2346
- addSystemText(C.red(`Error: ${err.message}`));
2347
- }
2348
- addSpacer();
2349
- return true;
2350
- }
2351
- case '/eval': {
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
- }
2358
- addSystemText(C.zinc600('Triggering evaluation...'));
2359
- tui.requestRender();
2360
- try {
2361
- const result = await sfClient.evaluate(resolvedThesisId);
2362
- addSystemText(C.emerald('Evaluation complete') + '\n' + C.zinc400(JSON.stringify(result, null, 2)));
2363
- }
2364
- catch (err) {
2365
- addSystemText(C.red(`Error: ${err.message}`));
2366
- }
2367
- addSpacer();
2368
- return true;
2369
- }
2370
- case '/model': {
2371
- const newModel = parts.slice(1).join(' ').trim();
2372
- if (!newModel) {
2373
- addSystemText(C.zinc400(`Current model: ${currentModelName}`));
2374
- return true;
2375
- }
2376
- addSpacer();
2377
- currentModelName = newModel.replace(/^openrouter\//, '');
2378
- model = resolveModel(currentModelName);
2379
- // Update agent model
2380
- agent.setModel(model);
2381
- footerBar.modelName = currentModelName;
2382
- footerBar.update();
2383
- addSystemText(C.emerald(`Model switched to ${currentModelName}`));
2384
- addSpacer();
2385
- tui.requestRender();
2386
- return true;
2387
- }
2388
- case '/switch': {
2389
- const newId = parts[1]?.trim();
2390
- if (!newId) {
2391
- addSystemText(C.zinc400('Usage: /switch <thesisId>'));
2392
- return true;
2393
- }
2394
- addSpacer();
2395
- try {
2396
- // Save current session
2397
- persistSession();
2398
- // Load new thesis context
2399
- const newContext = await sfClient.getContext(newId);
2400
- resolvedThesisId = newContext.thesisId || newId;
2401
- latestContext = newContext;
2402
- // Build new system prompt using the rich builder
2403
- const newSysPrompt = buildSystemPrompt(newContext);
2404
- const newConf = typeof newContext.confidence === 'number'
2405
- ? Math.round(newContext.confidence * 100) : 0;
2406
- // CRITICAL: Always clearMessages() first to reset agent internal state.
2407
- // replaceMessages() on a mid-conversation agent corrupts pi-agent-core's
2408
- // state machine, causing the TUI to freeze.
2409
- agent.clearMessages();
2410
- // Load saved session or start fresh
2411
- const saved = loadSession(resolvedThesisId);
2412
- if (saved?.messages?.length > 0) {
2413
- agent.replaceMessages(saved.messages);
2414
- agent.setSystemPrompt(newSysPrompt);
2415
- addSystemText(C.emerald(`Switched to ${resolvedThesisId.slice(0, 8)}`) + C.zinc400(` (resumed ${saved.messages.length} messages)`));
2416
- }
2417
- else {
2418
- agent.setSystemPrompt(newSysPrompt);
2419
- addSystemText(C.emerald(`Switched to ${resolvedThesisId.slice(0, 8)}`) + C.zinc400(' (new session)'));
2420
- }
2421
- // Update header
2422
- footerBar.setFromContext(newContext, initialPositions || undefined);
2423
- chatContainer.clear();
2424
- addSystemText(buildWelcomeDashboard(newContext, initialPositions));
2425
- }
2426
- catch (err) {
2427
- addSystemText(C.red(`Switch failed: ${err.message}`));
2428
- }
2429
- addSpacer();
2430
- // Force re-focus editor so input stays responsive
2431
- tui.setFocus(editor);
2432
- tui.requestRender();
2433
- return true;
2434
- }
2435
- case '/compact': {
2436
- addSpacer();
2437
- try {
2438
- const msgs = agent.state.messages;
2439
- if (msgs.length <= 10) {
2440
- addSystemText(C.zinc400('Conversation too short to compact'));
2441
- addSpacer();
2442
- tui.setFocus(editor);
2443
- return true;
2444
- }
2445
- // ── Find clean cut point ──────────────────────────────────────
2446
- // Walk backwards counting user messages as turn starts.
2447
- // Keep 3 complete turns. Never split a tool_call/tool_result pair.
2448
- const turnsToKeep = 3;
2449
- let turnsSeen = 0;
2450
- let cutIndex = msgs.length;
2451
- for (let i = msgs.length - 1; i >= 0; i--) {
2452
- if (msgs[i].role === 'user') {
2453
- turnsSeen++;
2454
- if (turnsSeen >= turnsToKeep) {
2455
- cutIndex = i;
2456
- break;
2457
- }
2458
- }
2459
- }
2460
- if (cutIndex <= 2) {
2461
- addSystemText(C.zinc400('Not enough complete turns to compact'));
2462
- addSpacer();
2463
- tui.setFocus(editor);
2464
- return true;
2465
- }
2466
- const toCompress = msgs.slice(0, cutIndex);
2467
- const toKeep = msgs.slice(cutIndex);
2468
- // ── Show loader ───────────────────────────────────────────────
2469
- const compactLoader = new Loader(tui, (s) => C.emerald(s), (s) => C.zinc600(s), 'compacting with LLM...');
2470
- compactLoader.start();
2471
- chatContainer.addChild(compactLoader);
2472
- tui.requestRender();
2473
- // ── Serialize messages for the summarizer ─────────────────────
2474
- // Strip tool results to raw text, cap total length to ~12k chars
2475
- const serialized = [];
2476
- let totalLen = 0;
2477
- const MAX_CHARS = 12000;
2478
- for (const m of toCompress) {
2479
- if (totalLen >= MAX_CHARS)
2480
- break;
2481
- let text = '';
2482
- if (typeof m.content === 'string') {
2483
- text = m.content;
2484
- }
2485
- else if (Array.isArray(m.content)) {
2486
- // OpenAI format: content blocks
2487
- text = m.content
2488
- .filter((b) => b.type === 'text')
2489
- .map((b) => b.text)
2490
- .join('\n');
2491
- }
2492
- if (!text)
2493
- continue;
2494
- const role = (m.role || 'unknown').toUpperCase();
2495
- const truncated = text.slice(0, 800);
2496
- const line = `[${role}]: ${truncated}`;
2497
- serialized.push(line);
2498
- totalLen += line.length;
2499
- }
2500
- const conversationDump = serialized.join('\n\n');
2501
- // ── Call OpenRouter for LLM summary ───────────────────────────
2502
- // Use a cheap/fast model — gemini flash
2503
- const summaryModel = 'google/gemini-2.0-flash-001';
2504
- const summarySystemPrompt = `You are a conversation compressor. Given a conversation between a user and a prediction-market trading assistant, produce a dense summary that preserves:
2505
- 1. All factual conclusions, numbers, prices, and probabilities mentioned
2506
- 2. Key trading decisions, positions taken or discussed
2507
- 3. Signals injected, evaluations triggered, and their outcomes
2508
- 4. Any action items or pending questions
2509
-
2510
- Output a structured summary. Be concise but preserve every important detail — this summary replaces the original messages for continued conversation. Do NOT add commentary or meta-text. Just the summary.`;
2511
- let summaryText;
2512
- try {
2513
- const compactUrl = useProxy
2514
- ? `${sfApiUrl}/api/proxy/llm`
2515
- : 'https://openrouter.ai/api/v1/chat/completions';
2516
- const compactBody = {
2517
- model: summaryModel,
2518
- messages: [
2519
- { role: 'system', content: summarySystemPrompt },
2520
- { role: 'user', content: `Summarize this conversation (${toCompress.length} messages):\n\n${conversationDump}` },
2521
- ],
2522
- max_tokens: 2000,
2523
- temperature: 0.2,
2524
- };
2525
- const orRes = await fetch(compactUrl, {
2526
- method: 'POST',
2527
- headers: {
2528
- 'Content-Type': 'application/json',
2529
- 'Authorization': `Bearer ${openrouterKey}`,
2530
- 'HTTP-Referer': 'https://simplefunctions.com',
2531
- 'X-Title': 'SF Agent Compact',
2532
- },
2533
- body: JSON.stringify(compactBody),
2534
- });
2535
- if (!orRes.ok) {
2536
- const errText = await orRes.text().catch(() => '');
2537
- throw new Error(`OpenRouter ${orRes.status}: ${errText.slice(0, 200)}`);
2538
- }
2539
- const orData = await orRes.json();
2540
- summaryText = orData.choices?.[0]?.message?.content || '';
2541
- if (!summaryText) {
2542
- throw new Error('Empty summary from LLM');
2543
- }
2544
- }
2545
- catch (llmErr) {
2546
- // LLM failed — fall back to bullet-point extraction
2547
- const bulletPoints = [];
2548
- for (const m of toCompress) {
2549
- const content = typeof m.content === 'string' ? m.content : '';
2550
- if (m.role === 'user' && content) {
2551
- bulletPoints.push(`- User: ${content.slice(0, 100)}`);
2552
- }
2553
- else if (m.role === 'assistant' && content) {
2554
- bulletPoints.push(`- Assistant: ${content.slice(0, 150)}`);
2555
- }
2556
- }
2557
- summaryText = `[LLM summary failed: ${llmErr.message}. Fallback bullet points:]\n\n${bulletPoints.slice(-20).join('\n')}`;
2558
- }
2559
- // ── Remove loader ─────────────────────────────────────────────
2560
- compactLoader.stop();
2561
- chatContainer.removeChild(compactLoader);
2562
- // ── Build compacted message array ──────────────────────────────
2563
- // user(summary) → assistant(ack) → ...toKeep
2564
- // This maintains valid user→assistant alternation.
2565
- // toKeep starts with a user message (guaranteed by our cut logic).
2566
- const compactedMessages = [
2567
- {
2568
- role: 'user',
2569
- content: `[Conversation summary — ${toCompress.length} messages compressed]\n\n${summaryText}`,
2570
- },
2571
- {
2572
- role: 'assistant',
2573
- content: 'Understood. I have the full conversation context from the summary above. Continuing from where we left off.',
2574
- },
2575
- ...toKeep,
2576
- ];
2577
- // ── Replace agent state ───────────────────────────────────────
2578
- // Clear first to reset internal state, then load compacted messages
2579
- agent.clearMessages();
2580
- agent.replaceMessages(compactedMessages);
2581
- agent.setSystemPrompt(systemPrompt);
2582
- persistSession();
2583
- addSystemText(C.emerald(`Compacted: ${toCompress.length} messages \u2192 summary + ${toKeep.length} recent`) +
2584
- C.zinc600(` (via ${summaryModel.split('/').pop()})`));
2585
- addSpacer();
2586
- // Force re-focus and render so editor stays responsive
2587
- tui.setFocus(editor);
2588
- tui.requestRender();
2589
- }
2590
- catch (err) {
2591
- addSystemText(C.red(`Compact failed: ${err.message || err}`));
2592
- addSpacer();
2593
- tui.setFocus(editor);
2594
- tui.requestRender();
2595
- }
2596
- return true;
2597
- }
2598
- case '/new': {
2599
- addSpacer();
2600
- persistSession(); // save current before clearing
2601
- agent.clearMessages();
2602
- agent.setSystemPrompt(systemPrompt);
2603
- chatContainer.clear();
2604
- addSystemText(C.emerald('Session cleared') + C.zinc400(' \u2014 fresh start'));
2605
- addSpacer();
2606
- tui.requestRender();
2607
- return true;
2608
- }
2609
- case '/env': {
2610
- addSpacer();
2611
- const envVars = [
2612
- { name: 'SF_API_KEY', key: 'SF_API_KEY', required: true, mask: true },
2613
- { name: 'SF_API_URL', key: 'SF_API_URL', required: false, mask: false },
2614
- { name: 'OPENROUTER_KEY', key: 'OPENROUTER_API_KEY', required: true, mask: true },
2615
- { name: 'KALSHI_KEY_ID', key: 'KALSHI_API_KEY_ID', required: false, mask: true },
2616
- { name: 'KALSHI_PEM_PATH', key: 'KALSHI_PRIVATE_KEY_PATH', required: false, mask: false },
2617
- { name: 'TAVILY_API_KEY', key: 'TAVILY_API_KEY', required: false, mask: true },
2618
- ];
2619
- const lines = envVars.map(v => {
2620
- const val = process.env[v.key];
2621
- if (val) {
2622
- const display = v.mask
2623
- ? val.slice(0, Math.min(8, val.length)) + '...' + val.slice(-4)
2624
- : val;
2625
- return ` ${v.name.padEnd(18)} ${C.emerald('\u2713')} ${C.zinc400(display)}`;
2626
- }
2627
- else {
2628
- const note = v.required ? '\u5FC5\u987B' : '\u53EF\u9009';
2629
- return ` ${v.name.padEnd(18)} ${C.red('\u2717')} ${C.zinc600(`\u672A\u914D\u7F6E\uFF08${note}\uFF09`)}`;
2630
- }
2631
- });
2632
- addSystemText(C.zinc200(bold('Environment')) + '\n' + lines.join('\n'));
2633
- addSpacer();
2634
- return true;
2635
- }
2636
- case '/clear': {
2637
- // Don't use chatContainer.clear() — it breaks pi-tui layout.
2638
- // Instead, remove children one by one and add a fresh spacer
2639
- // so the container still has content for layout calculations.
2640
- const children = [...chatContainer.children || []];
2641
- for (const child of children) {
2642
- try {
2643
- chatContainer.removeChild(child);
2644
- }
2645
- catch { /* ignore */ }
2646
- }
2647
- addSpacer();
2648
- isProcessing = false;
2649
- pendingPrompt = null;
2650
- tui.setFocus(editor);
2651
- tui.requestRender();
2652
- return true;
2653
- }
2654
- case '/buy': {
2655
- // /buy TICKER QTY PRICE — quick trade without LLM
2656
- const [, ticker, qtyStr, priceStr] = parts;
2657
- if (!ticker || !qtyStr || !priceStr) {
2658
- addSystemText(C.zinc400('Usage: /buy TICKER QTY PRICE_CENTS (e.g. /buy KXWTIMAX-26DEC31-T135 100 50)'));
2659
- return true;
2660
- }
2661
- if (!config.tradingEnabled) {
2662
- addSystemText(C.red('Trading disabled. Run: sf setup --enable-trading'));
2663
- return true;
2664
- }
2665
- addSpacer();
2666
- const answer = await promptUser(`BUY ${qtyStr}x ${ticker} YES @ ${priceStr}\u00A2 — execute? (y/n)`);
2667
- if (answer.toLowerCase().startsWith('y')) {
2668
- try {
2669
- const { createOrder } = await import('../kalshi.js');
2670
- const result = await createOrder({
2671
- ticker, side: 'yes', action: 'buy', type: 'limit',
2672
- count: parseInt(qtyStr),
2673
- yes_price: parseInt(priceStr),
2674
- });
2675
- addSystemText(C.emerald('\u2713 Order placed: ' + ((result.order || result).order_id || 'OK')));
2676
- }
2677
- catch (err) {
2678
- addSystemText(C.red('\u2717 ' + err.message));
2679
- }
2680
- }
2681
- else {
2682
- addSystemText(C.zinc400('Cancelled.'));
2683
- }
2684
- addSpacer();
2685
- return true;
2686
- }
2687
- case '/sell': {
2688
- const [, ticker, qtyStr, priceStr] = parts;
2689
- if (!ticker || !qtyStr || !priceStr) {
2690
- addSystemText(C.zinc400('Usage: /sell TICKER QTY PRICE_CENTS'));
2691
- return true;
2692
- }
2693
- if (!config.tradingEnabled) {
2694
- addSystemText(C.red('Trading disabled. Run: sf setup --enable-trading'));
2695
- return true;
2696
- }
2697
- addSpacer();
2698
- const answer = await promptUser(`SELL ${qtyStr}x ${ticker} YES @ ${priceStr}\u00A2 — execute? (y/n)`);
2699
- if (answer.toLowerCase().startsWith('y')) {
2700
- try {
2701
- const { createOrder } = await import('../kalshi.js');
2702
- const result = await createOrder({
2703
- ticker, side: 'yes', action: 'sell', type: 'limit',
2704
- count: parseInt(qtyStr),
2705
- yes_price: parseInt(priceStr),
2706
- });
2707
- addSystemText(C.emerald('\u2713 Order placed: ' + ((result.order || result).order_id || 'OK')));
2708
- }
2709
- catch (err) {
2710
- addSystemText(C.red('\u2717 ' + err.message));
2711
- }
2712
- }
2713
- else {
2714
- addSystemText(C.zinc400('Cancelled.'));
2715
- }
2716
- addSpacer();
2717
- return true;
2718
- }
2719
- case '/cancel': {
2720
- const [, orderId] = parts;
2721
- if (!orderId) {
2722
- addSystemText(C.zinc400('Usage: /cancel ORDER_ID'));
2723
- return true;
2724
- }
2725
- if (!config.tradingEnabled) {
2726
- addSystemText(C.red('Trading disabled. Run: sf setup --enable-trading'));
2727
- return true;
2728
- }
2729
- addSpacer();
2730
- try {
2731
- const { cancelOrder } = await import('../kalshi.js');
2732
- await cancelOrder(orderId);
2733
- addSystemText(C.emerald(`\u2713 Order ${orderId} cancelled.`));
2734
- }
2735
- catch (err) {
2736
- addSystemText(C.red('\u2717 ' + err.message));
2737
- }
2738
- addSpacer();
2739
- return true;
2740
- }
2741
- case '/exit':
2742
- case '/quit': {
2743
- cleanup();
2744
- return true;
2745
- }
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
- }
2767
- return false;
2768
- }
2769
- }
2770
- }
2771
- // ── Editor submit handler ──────────────────────────────────────────────────
2772
- editor.onSubmit = async (input) => {
2773
- const trimmed = input.trim();
2774
- if (!trimmed)
2775
- return;
2776
- // If a tool is waiting for user confirmation, resolve it
2777
- if (pendingPrompt) {
2778
- const { resolve } = pendingPrompt;
2779
- pendingPrompt = null;
2780
- const userResponse = new Text(C.zinc400(' > ') + C.zinc200(trimmed), 1, 0);
2781
- chatContainer.addChild(userResponse);
2782
- addSpacer();
2783
- tui.requestRender();
2784
- resolve(trimmed);
2785
- return;
2786
- }
2787
- if (isProcessing)
2788
- return;
2789
- // Add to editor history
2790
- editor.addToHistory(trimmed);
2791
- // Check for slash commands
2792
- if (trimmed.startsWith('/')) {
2793
- const handled = await handleSlashCommand(trimmed);
2794
- if (handled)
2795
- return;
2796
- }
2797
- // Regular message → send to agent
2798
- isProcessing = true;
2799
- // Add user message to chat
2800
- const userMsg = new Text(C.emerald(bold('>')) + ' ' + C.white(trimmed), 1, 0);
2801
- chatContainer.addChild(userMsg);
2802
- addSpacer();
2803
- tui.requestRender();
2804
- try {
2805
- await agent.prompt(trimmed);
2806
- }
2807
- catch (err) {
2808
- // Remove loader if present
2809
- if (currentLoader) {
2810
- currentLoader.stop();
2811
- chatContainer.removeChild(currentLoader);
2812
- currentLoader = null;
2813
- }
2814
- addSystemText(C.red(`Error: ${err.message}`));
2815
- addSpacer();
2816
- isProcessing = false;
2817
- }
2818
- };
2819
- // ── Ctrl+C handler ─────────────────────────────────────────────────────────
2820
- function cleanup() {
2821
- if (heartbeatPollTimer)
2822
- clearInterval(heartbeatPollTimer);
2823
- if (currentLoader)
2824
- currentLoader.stop();
2825
- persistSession();
2826
- tui.stop();
2827
- process.exit(0);
2828
- }
2829
- // Listen for Ctrl+C at the TUI level
2830
- tui.addInputListener((data) => {
2831
- // Ctrl+C = \x03
2832
- if (data === '\x03') {
2833
- cleanup();
2834
- return { consume: true };
2835
- }
2836
- return undefined;
2837
- });
2838
- // Also handle SIGINT
2839
- process.on('SIGINT', cleanup);
2840
- process.on('SIGTERM', cleanup);
2841
- // ── Welcome dashboard builder ────────────────────────────────────────────
2842
- function buildWelcomeDashboard(ctx, positions) {
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) ────────────────────────────────────
2872
- const thesisText = ctx.thesis || ctx.rawThesis || 'N/A';
2873
- const truncated = thesisText.length > 100 ? thesisText.slice(0, 100) + '...' : thesisText;
2874
- const conf = typeof ctx.confidence === 'number'
2875
- ? Math.round(ctx.confidence * 100)
2876
- : (typeof ctx.confidence === 'string' ? Math.round(parseFloat(ctx.confidence) * 100) : 0);
2877
- const delta = ctx.lastEvaluation?.confidenceDelta
2878
- ? Math.round(ctx.lastEvaluation.confidenceDelta * 100)
2879
- : 0;
2880
- const deltaStr = delta !== 0 ? ` (${delta > 0 ? '+' : ''}${delta})` : '';
2881
- const evalAge = ctx.lastEvaluation?.evaluatedAt
2882
- ? Math.round((Date.now() - new Date(ctx.lastEvaluation.evaluatedAt).getTime()) / 3600000)
2883
- : null;
2884
- lines.push(C.zinc600('\u2500'.repeat(55)));
2885
- lines.push(' ' + C.zinc200(bold(truncated)));
2886
- lines.push(' ' + C.zinc600(`${ctx.status || 'active'} ${conf}%${deltaStr}`) +
2887
- (evalAge !== null ? C.zinc600(` \u2502 last eval: ${evalAge < 1 ? '<1' : evalAge}h ago`) : ''));
2888
- lines.push(C.zinc600('\u2500'.repeat(55)));
2889
- // Positions section
2890
- if (positions && positions.length > 0) {
2891
- lines.push(' ' + C.zinc400(bold('POSITIONS')));
2892
- let totalPnl = 0;
2893
- for (const p of positions) {
2894
- const pnlCents = p.unrealized_pnl || 0;
2895
- totalPnl += pnlCents;
2896
- const pnlStr = pnlCents >= 0
2897
- ? C.emerald(`+$${(pnlCents / 100).toFixed(2)}`)
2898
- : C.red(`-$${(Math.abs(pnlCents) / 100).toFixed(2)}`);
2899
- const ticker = (p.ticker || '').slice(0, 28).padEnd(28);
2900
- const qty = String(p.quantity || 0).padStart(5);
2901
- const side = p.side === 'yes' ? C.emerald('Y') : C.red('N');
2902
- lines.push(` ${C.zinc400(ticker)} ${qty} ${side} ${pnlStr}`);
2903
- }
2904
- const totalStr = totalPnl >= 0
2905
- ? C.emerald(bold(`+$${(totalPnl / 100).toFixed(2)}`))
2906
- : C.red(bold(`-$${(Math.abs(totalPnl) / 100).toFixed(2)}`));
2907
- lines.push(` ${''.padEnd(28)} ${C.zinc600('Total')} ${totalStr}`);
2908
- }
2909
- // Top edges section
2910
- const edges = ctx.edges || [];
2911
- if (edges.length > 0) {
2912
- const sorted = [...edges].sort((a, b) => Math.abs(b.edge || b.edgeSize || 0) - Math.abs(a.edge || a.edgeSize || 0)).slice(0, 5);
2913
- lines.push(C.zinc600('\u2500'.repeat(55)));
2914
- lines.push(' ' + C.zinc400(bold('TOP EDGES')) + C.zinc600(' mkt edge liq'));
2915
- for (const e of sorted) {
2916
- const name = (e.market || e.marketTitle || e.marketId || '').slice(0, 30).padEnd(30);
2917
- const mkt = String(Math.round(e.marketPrice || 0)).padStart(3) + '\u00A2';
2918
- const edge = e.edge || e.edgeSize || 0;
2919
- const edgeStr = (edge > 0 ? '+' : '') + Math.round(edge);
2920
- const liq = e.orderbook?.liquidityScore || (e.venue === 'polymarket' ? '-' : '?');
2921
- const edgeColor = Math.abs(edge) >= 15 ? C.emerald : Math.abs(edge) >= 8 ? C.amber : C.zinc400;
2922
- lines.push(` ${C.zinc400(name)} ${C.zinc400(mkt)} ${edgeColor(edgeStr.padStart(4))} ${C.zinc600(liq)}`);
2923
- }
2924
- }
2925
- lines.push(C.zinc600('\u2500'.repeat(55)));
2926
- return lines.join('\n');
2927
- }
2928
- // ── Show initial welcome ───────────────────────────────────────────────────
2929
- const sessionStatus = sessionRestored
2930
- ? C.zinc600(`resumed (${agent.state.messages.length} messages)`)
2931
- : C.zinc600('new session');
2932
- addSystemText(buildWelcomeDashboard(latestContext, initialPositions));
2933
- addSystemText(' ' + sessionStatus);
2934
- addSpacer();
2935
- // ── Heartbeat delta handler ───────────────────────────────────────────────
2936
- const HEARTBEAT_CONFIDENCE_THRESHOLD = 0.03; // 3%
2937
- function handleHeartbeatDelta(delta) {
2938
- const absDelta = Math.abs(delta.confidenceDelta || 0);
2939
- const confPct = Math.round((delta.confidence || 0) * 100);
2940
- const deltaPct = Math.round((delta.confidenceDelta || 0) * 100);
2941
- const sign = deltaPct > 0 ? '+' : '';
2942
- if (absDelta >= HEARTBEAT_CONFIDENCE_THRESHOLD) {
2943
- // Big change → auto-trigger agent analysis
2944
- const arrow = deltaPct > 0 ? '\u25B2' : '\u25BC';
2945
- const color = deltaPct > 0 ? C.emerald : C.red;
2946
- addSystemText(color(` ${arrow} Heartbeat: confidence ${sign}${deltaPct}% → ${confPct}%`));
2947
- if (delta.latestSummary) {
2948
- addSystemText(C.zinc400(` ${delta.latestSummary.slice(0, 100)}`));
2949
- }
2950
- addSpacer();
2951
- // Update header
2952
- footerBar.setFromContext({ ...latestContext, confidence: delta.confidence, lastEvaluation: { confidenceDelta: delta.confidenceDelta } }, initialPositions || undefined);
2953
- tui.requestRender();
2954
- // Auto-trigger agent
2955
- isProcessing = true;
2956
- const prompt = `[HEARTBEAT ALERT] Confidence just changed ${sign}${deltaPct}% to ${confPct}%. ${delta.evaluationCount} evaluation(s) since last check. Latest: "${(delta.latestSummary || '').slice(0, 150)}". Briefly analyze what happened and whether any action is needed. Be concise.`;
2957
- agent.prompt(prompt).catch((err) => {
2958
- addSystemText(C.red(`Error: ${err.message}`));
2959
- isProcessing = false;
2960
- });
2961
- }
2962
- else if (absDelta > 0) {
2963
- // Small change → silent notification line only
2964
- addSystemText(C.zinc600(` \u2500 heartbeat: ${confPct}% (${sign}${deltaPct}%) \u2014 ${delta.evaluationCount || 0} eval(s)`));
2965
- tui.requestRender();
2966
- }
2967
- // absDelta === 0: truly nothing changed, stay silent
2968
- }
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
- }
2984
- }
2985
- catch {
2986
- // Silent — don't spam errors from background polling
2987
- }
2988
- }, 60_000); // every 60 seconds
2989
- // ── Start TUI ──────────────────────────────────────────────────────────────
2990
- tui.start();
2991
- }
2992
- // ============================================================================
2993
- // PLAIN-TEXT MODE (--no-tui)
2994
- // ============================================================================
2995
- async function runPlainTextAgent(params) {
2996
- const { openrouterKey, sfClient, resolvedThesisId, opts, useProxy, llmBaseUrl, sfApiKey, sfApiUrl } = params;
2997
- let latestContext = params.latestContext;
2998
- const readline = await import('readline');
2999
- const piAi = await import('@mariozechner/pi-ai');
3000
- const piAgent = await import('@mariozechner/pi-agent-core');
3001
- const { getModel, streamSimple, Type } = piAi;
3002
- const { Agent } = piAgent;
3003
- const rawModelName = opts?.model || 'anthropic/claude-sonnet-4.6';
3004
- let currentModelName = rawModelName.replace(/^openrouter\//, '');
3005
- function resolveModel(name) {
3006
- let m;
3007
- try {
3008
- m = getModel('openrouter', name);
3009
- }
3010
- catch {
3011
- m = {
3012
- modelId: name, provider: 'openrouter', api: 'openai-completions',
3013
- baseUrl: 'https://openrouter.ai/api/v1', id: name, name,
3014
- inputPrice: 0, outputPrice: 0, contextWindow: 200000,
3015
- supportsImages: true, supportsTools: true,
3016
- };
3017
- }
3018
- if (useProxy)
3019
- m.baseUrl = llmBaseUrl;
3020
- return m;
3021
- }
3022
- let model = resolveModel(currentModelName);
3023
- // ── Tools (same definitions as TUI mode) ──────────────────────────────────
3024
- const thesisIdParam = Type.Object({ thesisId: Type.String({ description: 'Thesis ID' }) });
3025
- const signalParams = Type.Object({
3026
- thesisId: Type.String({ description: 'Thesis ID' }),
3027
- content: Type.String({ description: 'Signal content' }),
3028
- type: Type.Optional(Type.String({ description: 'Signal type: news, user_note, external' })),
3029
- });
3030
- const scanParams = Type.Object({
3031
- query: Type.Optional(Type.String({ description: 'Keyword search' })),
3032
- series: Type.Optional(Type.String({ description: 'Series ticker' })),
3033
- market: Type.Optional(Type.String({ description: 'Market ticker' })),
3034
- });
3035
- const webSearchParams = Type.Object({ query: Type.String({ description: 'Search keywords' }) });
3036
- const emptyParams = Type.Object({});
3037
- const tools = [
3038
- {
3039
- name: 'get_context', label: 'Get Context',
3040
- description: 'Get thesis snapshot: causal tree, edge prices, last evaluation, confidence',
3041
- parameters: thesisIdParam,
3042
- execute: async (_id, p) => {
3043
- const ctx = await sfClient.getContext(p.thesisId);
3044
- latestContext = ctx;
3045
- return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }], details: {} };
3046
- },
3047
- },
3048
- {
3049
- name: 'global_context', label: 'Market Snapshot',
3050
- description: 'Global market snapshot — top movers, expiring contracts, milestones, liquidity, signals. No thesis needed.',
3051
- parameters: emptyParams,
3052
- execute: async () => {
3053
- const { fetchGlobalContext } = await import('../client.js');
3054
- const data = await fetchGlobalContext();
3055
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3056
- },
3057
- },
3058
- {
3059
- name: 'query', label: 'Query',
3060
- description: 'LLM-enhanced prediction market knowledge search. Returns structured answer with live prices, thesis data, key factors.',
3061
- parameters: Type.Object({ q: Type.String({ description: 'Natural language query' }) }),
3062
- execute: async (_id, p) => {
3063
- const { fetchQuery } = await import('../client.js');
3064
- const data = await fetchQuery(p.q);
3065
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3066
- },
3067
- },
3068
- {
3069
- name: 'get_markets', label: 'Traditional Markets',
3070
- description: 'Traditional market prices via Databento: SPY, VIX, Treasury, Gold, Oil. Daily close + change.',
3071
- parameters: emptyParams,
3072
- execute: async () => {
3073
- const { fetchTraditionalMarkets } = await import('../client.js');
3074
- const data = await fetchTraditionalMarkets();
3075
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3076
- },
3077
- },
3078
- {
3079
- name: 'inject_signal', label: 'Inject Signal',
3080
- description: 'Inject a signal into the thesis',
3081
- parameters: signalParams,
3082
- execute: async (_id, p) => {
3083
- const result = await sfClient.injectSignal(p.thesisId, p.type || 'user_note', p.content);
3084
- return { content: [{ type: 'text', text: JSON.stringify(result) }], details: {} };
3085
- },
3086
- },
3087
- {
3088
- name: 'trigger_evaluation', label: 'Evaluate',
3089
- description: 'Trigger a deep evaluation cycle',
3090
- parameters: thesisIdParam,
3091
- execute: async (_id, p) => {
3092
- const result = await sfClient.evaluate(p.thesisId);
3093
- return { content: [{ type: 'text', text: JSON.stringify(result) }], details: {} };
3094
- },
3095
- },
3096
- {
3097
- name: 'scan_markets', label: 'Scan Markets',
3098
- description: 'Search Kalshi prediction markets',
3099
- parameters: scanParams,
3100
- execute: async (_id, p) => {
3101
- let result;
3102
- if (p.market) {
3103
- result = await (0, client_js_1.kalshiFetchMarket)(p.market);
3104
- }
3105
- else if (p.series) {
3106
- result = await (0, client_js_1.kalshiFetchMarketsBySeries)(p.series);
3107
- }
3108
- else if (p.query) {
3109
- const series = await (0, client_js_1.kalshiFetchAllSeries)();
3110
- const kws = p.query.toLowerCase().split(/\s+/);
3111
- result = series.filter((s) => kws.every((k) => ((s.title || '') + (s.ticker || '')).toLowerCase().includes(k))).filter((s) => parseFloat(s.volume_fp || '0') > 1000).sort((a, b) => parseFloat(b.volume_fp || '0') - parseFloat(a.volume_fp || '0')).slice(0, 15);
3112
- }
3113
- else {
3114
- result = { error: 'Provide query, series, or market' };
3115
- }
3116
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
3117
- },
3118
- },
3119
- {
3120
- name: 'list_theses', label: 'List Theses',
3121
- description: 'List all theses',
3122
- parameters: emptyParams,
3123
- execute: async () => {
3124
- const theses = await sfClient.listTheses();
3125
- return { content: [{ type: 'text', text: JSON.stringify(theses, null, 2) }], details: {} };
3126
- },
3127
- },
3128
- {
3129
- name: 'get_positions', label: 'Get Positions',
3130
- description: 'Get Kalshi positions with live prices',
3131
- parameters: emptyParams,
3132
- execute: async () => {
3133
- const positions = await (0, kalshi_js_1.getPositions)();
3134
- if (!positions)
3135
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
3136
- for (const pos of positions) {
3137
- const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
3138
- if (livePrice !== null) {
3139
- pos.current_value = livePrice;
3140
- pos.unrealized_pnl = Math.round((livePrice - pos.average_price_paid) * pos.quantity);
3141
- }
3142
- }
3143
- const formatted = positions.map((p) => ({
3144
- ticker: p.ticker, side: p.side, quantity: p.quantity,
3145
- avg_price: `${p.average_price_paid}¢`, current_price: `${p.current_value}¢`,
3146
- unrealized_pnl: `$${(p.unrealized_pnl / 100).toFixed(2)}`,
3147
- total_cost: `$${(p.total_cost / 100).toFixed(2)}`,
3148
- realized_pnl: `$${(p.realized_pnl / 100).toFixed(2)}`,
3149
- }));
3150
- return { content: [{ type: 'text', text: JSON.stringify(formatted, null, 2) }], details: {} };
3151
- },
3152
- },
3153
- {
3154
- name: 'web_search', label: 'Web Search',
3155
- description: 'Search latest news and information',
3156
- parameters: webSearchParams,
3157
- execute: async (_id, p) => {
3158
- const tavilyKey2 = process.env.TAVILY_API_KEY;
3159
- const canProxy2 = !tavilyKey2 && sfApiKey;
3160
- if (!tavilyKey2 && !canProxy2)
3161
- return { content: [{ type: 'text', text: 'Web search not available. Run sf login or set TAVILY_API_KEY.' }], details: {} };
3162
- let res;
3163
- if (tavilyKey2) {
3164
- res = await fetch('https://api.tavily.com/search', {
3165
- method: 'POST', headers: { 'Content-Type': 'application/json' },
3166
- body: JSON.stringify({ api_key: tavilyKey2, query: p.query, max_results: 5, search_depth: 'basic', include_answer: true }),
3167
- });
3168
- }
3169
- else {
3170
- res = await fetch(`${sfApiUrl}/api/proxy/search`, {
3171
- method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sfApiKey}` },
3172
- body: JSON.stringify({ query: p.query, max_results: 5, search_depth: 'basic', include_answer: true }),
3173
- });
3174
- }
3175
- if (!res.ok)
3176
- return { content: [{ type: 'text', text: `Search failed: ${res.status}` }], details: {} };
3177
- const data = await res.json();
3178
- const results = (data.results || []).map((r) => `[${r.title}](${r.url})\n${r.content?.slice(0, 200)}`).join('\n\n');
3179
- const answer = data.answer ? `Summary: ${data.answer}\n\n---\n\n` : '';
3180
- return { content: [{ type: 'text', text: `${answer}${results}` }], details: {} };
3181
- },
3182
- },
3183
- {
3184
- name: 'get_milestones', label: 'Milestones',
3185
- description: 'Get upcoming events from Kalshi calendar. Use to check economic releases, political events, or other catalysts.',
3186
- parameters: Type.Object({
3187
- hours: Type.Optional(Type.Number({ description: 'Hours ahead to look (default 168 = 1 week)' })),
3188
- category: Type.Optional(Type.String({ description: 'Filter by category (e.g. Economics, Politics, Sports)' })),
3189
- }),
3190
- execute: async (_id, p) => {
3191
- const hours = p.hours || 168;
3192
- const now = new Date();
3193
- const url = `https://api.elections.kalshi.com/trade-api/v2/milestones?limit=200&minimum_start_date=${now.toISOString()}` +
3194
- (p.category ? `&category=${p.category}` : '');
3195
- const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
3196
- if (!res.ok)
3197
- return { content: [{ type: 'text', text: `Milestones API error: ${res.status}` }], details: {} };
3198
- const data = await res.json();
3199
- const cutoff = now.getTime() + hours * 3600000;
3200
- const filtered = (data.milestones || [])
3201
- .filter((m) => new Date(m.start_date).getTime() <= cutoff)
3202
- .slice(0, 30)
3203
- .map((m) => ({
3204
- title: m.title, category: m.category, start_date: m.start_date,
3205
- related_event_tickers: m.related_event_tickers,
3206
- hours_until: Math.round((new Date(m.start_date).getTime() - now.getTime()) / 3600000),
3207
- }));
3208
- return { content: [{ type: 'text', text: JSON.stringify(filtered, null, 2) }], details: {} };
3209
- },
3210
- },
3211
- {
3212
- name: 'get_forecast', label: 'Forecast',
3213
- description: 'Get market distribution (P50/P75/P90 percentile history) for a Kalshi event.',
3214
- parameters: Type.Object({
3215
- eventTicker: Type.String({ description: 'Kalshi event ticker (e.g. KXWTIMAX-26DEC31)' }),
3216
- days: Type.Optional(Type.Number({ description: 'Days of history (default 7)' })),
3217
- }),
3218
- execute: async (_id, p) => {
3219
- const { getForecastHistory } = await import('../kalshi.js');
3220
- const days = p.days || 7;
3221
- const evtRes = await fetch(`https://api.elections.kalshi.com/trade-api/v2/events/${p.eventTicker}`, { headers: { 'Accept': 'application/json' } });
3222
- if (!evtRes.ok)
3223
- return { content: [{ type: 'text', text: `Event not found: ${p.eventTicker}` }], details: {} };
3224
- const evtData = await evtRes.json();
3225
- const seriesTicker = evtData.event?.series_ticker;
3226
- if (!seriesTicker)
3227
- return { content: [{ type: 'text', text: `No series_ticker for ${p.eventTicker}` }], details: {} };
3228
- const history = await getForecastHistory({
3229
- seriesTicker, eventTicker: p.eventTicker, percentiles: [5000, 7500, 9000],
3230
- startTs: Math.floor((Date.now() - days * 86400000) / 1000),
3231
- endTs: Math.floor(Date.now() / 1000), periodInterval: 1440,
3232
- });
3233
- if (!history || history.length === 0)
3234
- return { content: [{ type: 'text', text: 'No forecast data available' }], details: {} };
3235
- return { content: [{ type: 'text', text: JSON.stringify(history, null, 2) }], details: {} };
3236
- },
3237
- },
3238
- {
3239
- name: 'get_settlements', label: 'Settlements',
3240
- description: 'Get settled (resolved) contracts with P&L.',
3241
- parameters: Type.Object({ ticker: Type.Optional(Type.String({ description: 'Filter by market ticker' })) }),
3242
- execute: async (_id, p) => {
3243
- const { getSettlements } = await import('../kalshi.js');
3244
- const result = await getSettlements({ limit: 100, ticker: p.ticker });
3245
- if (!result)
3246
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
3247
- return { content: [{ type: 'text', text: JSON.stringify(result.settlements, null, 2) }], details: {} };
3248
- },
3249
- },
3250
- {
3251
- name: 'get_balance', label: 'Balance',
3252
- description: 'Get Kalshi account balance and portfolio value.',
3253
- parameters: emptyParams,
3254
- execute: async () => {
3255
- const { getBalance } = await import('../kalshi.js');
3256
- const result = await getBalance();
3257
- if (!result)
3258
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
3259
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
3260
- },
3261
- },
3262
- {
3263
- name: 'get_orders', label: 'Orders',
3264
- description: 'Get current resting orders on Kalshi.',
3265
- parameters: Type.Object({ status: Type.Optional(Type.String({ description: 'Filter by status: resting, canceled, executed. Default: resting' })) }),
3266
- execute: async (_id, p) => {
3267
- const { getOrders } = await import('../kalshi.js');
3268
- const result = await getOrders({ status: p.status || 'resting', limit: 100 });
3269
- if (!result)
3270
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
3271
- return { content: [{ type: 'text', text: JSON.stringify(result.orders, null, 2) }], details: {} };
3272
- },
3273
- },
3274
- {
3275
- name: 'get_fills', label: 'Fills',
3276
- description: 'Get recent trade fills (executed trades) on Kalshi.',
3277
- parameters: Type.Object({ ticker: Type.Optional(Type.String({ description: 'Filter by market ticker' })) }),
3278
- execute: async (_id, p) => {
3279
- const { getFills } = await import('../kalshi.js');
3280
- const result = await getFills({ ticker: p.ticker, limit: 50 });
3281
- if (!result)
3282
- return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
3283
- return { content: [{ type: 'text', text: JSON.stringify(result.fills, null, 2) }], details: {} };
3284
- },
3285
- },
3286
- {
3287
- name: 'get_liquidity',
3288
- label: 'Liquidity Scanner',
3289
- description: 'Scan orderbook liquidity for a topic across Kalshi + Polymarket. Returns spread, depth, liquidity scores. Topics: ' + Object.keys(topics_js_1.TOPIC_SERIES).join(', '),
3290
- parameters: Type.Object({
3291
- topic: Type.String({ description: 'Topic to scan (e.g. oil, crypto, fed, geopolitics)' }),
3292
- }),
3293
- execute: async (_toolCallId, params) => {
3294
- const topicKey = params.topic.toLowerCase();
3295
- const seriesList = topics_js_1.TOPIC_SERIES[topicKey];
3296
- if (!seriesList) {
3297
- return { content: [{ type: 'text', text: `Unknown topic "${params.topic}". Available: ${Object.keys(topics_js_1.TOPIC_SERIES).join(', ')}` }], details: {} };
3298
- }
3299
- const results = [];
3300
- for (const series of seriesList) {
3301
- try {
3302
- const url = `https://api.elections.kalshi.com/trade-api/v2/markets?series_ticker=${series}&status=open&limit=200`;
3303
- const res = await fetch(url, { headers: { Accept: 'application/json' } });
3304
- if (!res.ok)
3305
- continue;
3306
- const markets = (await res.json()).markets || [];
3307
- const obResults = await Promise.allSettled(markets.slice(0, 20).map((m) => (0, kalshi_js_1.getPublicOrderbook)(m.ticker).then(ob => ({ ticker: m.ticker, title: m.title, ob }))));
3308
- for (const r of obResults) {
3309
- if (r.status !== 'fulfilled' || !r.value.ob)
3310
- continue;
3311
- const { ticker, title, ob } = r.value;
3312
- const yes = (ob.yes_dollars || []).map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), qty: parseFloat(q) })).filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
3313
- const no = (ob.no_dollars || []).map(([p, q]) => ({ price: Math.round(parseFloat(p) * 100), qty: parseFloat(q) })).filter((l) => l.price > 0).sort((a, b) => b.price - a.price);
3314
- const bestBid = yes[0]?.price || 0;
3315
- const bestAsk = no.length > 0 ? (100 - no[0].price) : 100;
3316
- const spread = bestAsk - bestBid;
3317
- const depth = yes.slice(0, 3).reduce((s, l) => s + l.qty, 0) + no.slice(0, 3).reduce((s, l) => s + l.qty, 0);
3318
- const liq = spread <= 2 && depth >= 500 ? 'high' : spread <= 5 && depth >= 100 ? 'medium' : 'low';
3319
- results.push({ venue: 'kalshi', ticker, title: (title || '').slice(0, 50), bestBid, bestAsk, spread, depth: Math.round(depth), liquidityScore: liq });
3320
- }
3321
- }
3322
- catch { /* skip */ }
3323
- }
3324
- try {
3325
- const events = await (0, polymarket_js_1.polymarketSearch)(topicKey, 5);
3326
- for (const event of events) {
3327
- for (const m of (event.markets || []).slice(0, 5)) {
3328
- if (!m.active || m.closed || !m.clobTokenIds)
3329
- continue;
3330
- const ids = (0, polymarket_js_1.parseClobTokenIds)(m.clobTokenIds);
3331
- if (!ids)
3332
- continue;
3333
- const d = await (0, polymarket_js_1.polymarketGetOrderbookWithDepth)(ids[0]);
3334
- if (!d)
3335
- continue;
3336
- results.push({ venue: 'polymarket', ticker: (m.question || event.title).slice(0, 50), bestBid: d.bestBid, bestAsk: d.bestAsk, spread: d.spread, depth: d.bidDepthTop3 + d.askDepthTop3, liquidityScore: d.liquidityScore });
3337
- }
3338
- }
3339
- }
3340
- catch { /* skip */ }
3341
- results.sort((a, b) => a.spread - b.spread);
3342
- return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], details: {} };
3343
- },
3344
- },
3345
- {
3346
- name: 'inspect_book',
3347
- label: 'Orderbook',
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.',
3349
- parameters: Type.Object({
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"])' })),
3352
- polyQuery: Type.Optional(Type.String({ description: 'Search Polymarket by keyword (e.g. "oil price above 100")' })),
3353
- }),
3354
- execute: async (_toolCallId, params) => {
3355
- const results = [];
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) {
3363
- try {
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
- }
3410
- }
3411
- catch (err) {
3412
- results.push({ venue: 'kalshi', ticker: tkr, status: 'api_error', reason: `Kalshi API error: ${err.message}` });
3413
- }
3414
- }
3415
- if (params.polyQuery) {
3416
- try {
3417
- const events = await (0, polymarket_js_1.polymarketSearch)(params.polyQuery, 5);
3418
- for (const event of events) {
3419
- for (const m of (event.markets || []).slice(0, 3)) {
3420
- if (!m.active || m.closed || !m.clobTokenIds)
3421
- continue;
3422
- const ids = (0, polymarket_js_1.parseClobTokenIds)(m.clobTokenIds);
3423
- if (!ids)
3424
- continue;
3425
- const depth = await (0, polymarket_js_1.polymarketGetOrderbookWithDepth)(ids[0]);
3426
- if (!depth)
3427
- continue;
3428
- const prices = (0, polymarket_js_1.parseOutcomePrices)(m.outcomePrices);
3429
- results.push({
3430
- venue: 'polymarket', title: m.question || event.title,
3431
- bestBid: depth.bestBid, bestAsk: depth.bestAsk, spread: depth.spread,
3432
- liquidityScore: depth.liquidityScore,
3433
- totalBidDepth: depth.totalBidDepth, totalAskDepth: depth.totalAskDepth,
3434
- lastPrice: prices[0] ? Math.round(prices[0] * 100) : 0,
3435
- volume24h: m.volume24hr || 0,
3436
- });
3437
- }
3438
- }
3439
- }
3440
- catch { /* skip */ }
3441
- }
3442
- if (results.length === 0) {
3443
- return { content: [{ type: 'text', text: 'No markets found. Provide ticker (Kalshi) or polyQuery (Polymarket search).' }], details: {} };
3444
- }
3445
- return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], details: {} };
3446
- },
3447
- },
3448
- {
3449
- name: 'get_schedule',
3450
- label: 'Schedule',
3451
- description: 'Get exchange status (open/closed) and trading hours. Use to check if low liquidity is due to off-hours.',
3452
- parameters: emptyParams,
3453
- execute: async () => {
3454
- try {
3455
- const res = await fetch('https://api.elections.kalshi.com/trade-api/v2/exchange/status', { headers: { 'Accept': 'application/json' } });
3456
- if (!res.ok)
3457
- return { content: [{ type: 'text', text: `Exchange API error: ${res.status}` }], details: {} };
3458
- const data = await res.json();
3459
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3460
- }
3461
- catch (err) {
3462
- return { content: [{ type: 'text', text: `Failed: ${err.message}` }], details: {} };
3463
- }
3464
- },
3465
- },
3466
- {
3467
- name: 'create_thesis',
3468
- label: 'Create Thesis',
3469
- description: 'Create a new thesis from a raw thesis statement. Returns the thesis ID, confidence, node count, and edge count.',
3470
- parameters: Type.Object({
3471
- rawThesis: Type.String({ description: 'The raw thesis statement to create' }),
3472
- webhookUrl: Type.Optional(Type.String({ description: 'Optional webhook URL for notifications' })),
3473
- }),
3474
- execute: async (_id, p) => {
3475
- const result = await sfClient.createThesis(p.rawThesis, true);
3476
- const thesis = result.thesis || result;
3477
- const nodeCount = thesis.causalTree?.nodes?.length || 0;
3478
- const edgeCount = (thesis.edges || []).length;
3479
- const confidence = typeof thesis.confidence === 'number' ? Math.round(thesis.confidence * 100) : 0;
3480
- return {
3481
- content: [{ type: 'text', text: `Thesis created.\nID: ${thesis.id}\nConfidence: ${confidence}%\nNodes: ${nodeCount}\nEdges: ${edgeCount}` }],
3482
- details: {},
3483
- };
3484
- },
3485
- },
3486
- {
3487
- name: 'get_edges',
3488
- label: 'Get Edges',
3489
- description: 'Get top edges across all active theses. Returns the top 10 edges sorted by absolute edge size with ticker, market name, edge size, direction, and venue.',
3490
- parameters: emptyParams,
3491
- execute: async () => {
3492
- const { theses } = await sfClient.listTheses();
3493
- const activeTheses = (theses || []).filter((t) => t.status === 'active' || t.status === 'monitoring');
3494
- const results = await Promise.allSettled(activeTheses.map(async (t) => {
3495
- const ctx = await sfClient.getContext(t.id);
3496
- return (ctx.edges || []).map((e) => ({ ...e, thesisId: t.id }));
3497
- }));
3498
- const allEdges = [];
3499
- for (const r of results) {
3500
- if (r.status === 'fulfilled')
3501
- allEdges.push(...r.value);
3502
- }
3503
- allEdges.sort((a, b) => Math.abs(b.edge || b.edgeSize || 0) - Math.abs(a.edge || a.edgeSize || 0));
3504
- const top10 = allEdges.slice(0, 10).map((e) => ({
3505
- ticker: e.marketId || e.ticker || '-',
3506
- market: e.market || e.marketTitle || '-',
3507
- edge: e.edge || e.edgeSize || 0,
3508
- direction: e.direction || 'yes',
3509
- venue: e.venue || 'kalshi',
3510
- }));
3511
- return {
3512
- content: [{ type: 'text', text: JSON.stringify(top10, null, 2) }],
3513
- details: {},
3514
- };
3515
- },
3516
- },
3517
- {
3518
- name: 'get_feed',
3519
- label: 'Get Feed',
3520
- description: 'Get evaluation history with topSignal highlighting. The most important signal is surfaced first.',
3521
- parameters: Type.Object({
3522
- hours: Type.Optional(Type.Number({ description: 'Hours of history to fetch (default 24)' })),
3523
- }),
3524
- execute: async (_id, p) => {
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;
3551
- return {
3552
- content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
3553
- details: {},
3554
- };
3555
- },
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
- },
3575
- {
3576
- name: 'explore_public',
3577
- label: 'Explore Public Theses',
3578
- description: 'Browse public theses from other users. No auth required. Pass a slug to get details, or omit to list all.',
3579
- parameters: Type.Object({
3580
- slug: Type.Optional(Type.String({ description: 'Specific thesis slug, or empty to list all' })),
3581
- }),
3582
- execute: async (_id, p) => {
3583
- const base = 'https://simplefunctions.dev';
3584
- if (p.slug) {
3585
- const res = await fetch(`${base}/api/public/thesis/${p.slug}`);
3586
- if (!res.ok)
3587
- return { content: [{ type: 'text', text: `Not found: ${p.slug}` }], details: {} };
3588
- const data = await res.json();
3589
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3590
- }
3591
- const res = await fetch(`${base}/api/public/theses`);
3592
- if (!res.ok)
3593
- return { content: [{ type: 'text', text: 'Failed to fetch public theses' }], details: {} };
3594
- const data = await res.json();
3595
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3596
- },
3597
- },
3598
- {
3599
- name: 'create_strategy',
3600
- label: 'Create Strategy',
3601
- description: 'Create a trading strategy for a thesis. Extract hard conditions (entryBelow/stopLoss/takeProfit as cents) and soft conditions from conversation. Called when user mentions specific trade ideas.',
3602
- parameters: Type.Object({
3603
- thesisId: Type.String({ description: 'Thesis ID' }),
3604
- marketId: Type.String({ description: 'Market ticker e.g. KXWTIMAX-26DEC31-T150' }),
3605
- market: Type.String({ description: 'Human-readable market name' }),
3606
- direction: Type.String({ description: 'yes or no' }),
3607
- horizon: Type.Optional(Type.String({ description: 'short, medium, or long. Default: medium' })),
3608
- entryBelow: Type.Optional(Type.Number({ description: 'Entry trigger: ask <= this value (cents)' })),
3609
- entryAbove: Type.Optional(Type.Number({ description: 'Entry trigger: ask >= this value (cents, for NO direction)' })),
3610
- stopLoss: Type.Optional(Type.Number({ description: 'Stop loss: bid <= this value (cents)' })),
3611
- takeProfit: Type.Optional(Type.Number({ description: 'Take profit: bid >= this value (cents)' })),
3612
- maxQuantity: Type.Optional(Type.Number({ description: 'Max total contracts. Default: 500' })),
3613
- perOrderQuantity: Type.Optional(Type.Number({ description: 'Contracts per order. Default: 50' })),
3614
- softConditions: Type.Optional(Type.String({ description: 'LLM-evaluated conditions e.g. "only enter when n3 > 60%"' })),
3615
- rationale: Type.Optional(Type.String({ description: 'Full logic description' })),
3616
- }),
3617
- execute: async (_id, p) => {
3618
- const result = await sfClient.createStrategyAPI(p.thesisId, {
3619
- marketId: p.marketId,
3620
- market: p.market,
3621
- direction: p.direction,
3622
- horizon: p.horizon,
3623
- entryBelow: p.entryBelow,
3624
- entryAbove: p.entryAbove,
3625
- stopLoss: p.stopLoss,
3626
- takeProfit: p.takeProfit,
3627
- maxQuantity: p.maxQuantity,
3628
- perOrderQuantity: p.perOrderQuantity,
3629
- softConditions: p.softConditions,
3630
- rationale: p.rationale,
3631
- createdBy: 'agent',
3632
- });
3633
- return { content: [{ type: 'text', text: JSON.stringify(result) }], details: {} };
3634
- },
3635
- },
3636
- {
3637
- name: 'list_strategies',
3638
- label: 'List Strategies',
3639
- description: 'List strategies for a thesis. Filter by status (active/watching/executed/cancelled/review) or omit for all.',
3640
- parameters: Type.Object({
3641
- thesisId: Type.String({ description: 'Thesis ID' }),
3642
- status: Type.Optional(Type.String({ description: 'Filter by status. Omit for all.' })),
3643
- }),
3644
- execute: async (_id, p) => {
3645
- const result = await sfClient.getStrategies(p.thesisId, p.status);
3646
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
3647
- },
3648
- },
3649
- {
3650
- name: 'update_strategy',
3651
- label: 'Update Strategy',
3652
- description: 'Update a strategy (change stop loss, take profit, status, priority, etc.)',
3653
- parameters: Type.Object({
3654
- thesisId: Type.String({ description: 'Thesis ID' }),
3655
- strategyId: Type.String({ description: 'Strategy ID (UUID)' }),
3656
- stopLoss: Type.Optional(Type.Number({ description: 'New stop loss (cents)' })),
3657
- takeProfit: Type.Optional(Type.Number({ description: 'New take profit (cents)' })),
3658
- entryBelow: Type.Optional(Type.Number({ description: 'New entry below trigger (cents)' })),
3659
- entryAbove: Type.Optional(Type.Number({ description: 'New entry above trigger (cents)' })),
3660
- status: Type.Optional(Type.String({ description: 'New status: active|watching|executed|cancelled|review' })),
3661
- priority: Type.Optional(Type.Number({ description: 'New priority' })),
3662
- softConditions: Type.Optional(Type.String({ description: 'Updated soft conditions' })),
3663
- rationale: Type.Optional(Type.String({ description: 'Updated rationale' })),
3664
- }),
3665
- execute: async (_id, p) => {
3666
- const { thesisId, strategyId, ...updates } = p;
3667
- const result = await sfClient.updateStrategyAPI(thesisId, strategyId, updates);
3668
- return { content: [{ type: 'text', text: JSON.stringify(result) }], details: {} };
3669
- },
3670
- },
3671
- {
3672
- name: 'what_if',
3673
- label: 'What-If',
3674
- description: 'Run a what-if scenario: override causal tree node probabilities and see how edges and confidence change. Zero LLM cost — pure computation.',
3675
- parameters: Type.Object({
3676
- overrides: Type.Array(Type.Object({
3677
- nodeId: Type.String({ description: 'Causal tree node ID (e.g. n1, n3.1)' }),
3678
- newProbability: Type.Number({ description: 'New probability 0-1' }),
3679
- }), { description: 'Node probability overrides' }),
3680
- }),
3681
- execute: async (_id, p) => {
3682
- const ctx = latestContext;
3683
- const allNodes = [];
3684
- function flatten(nodes) {
3685
- for (const n of nodes) {
3686
- allNodes.push(n);
3687
- if (n.children?.length)
3688
- flatten(n.children);
3689
- }
3690
- }
3691
- const rawNodes = ctx.causalTree?.nodes || [];
3692
- flatten(rawNodes);
3693
- const treeNodes = rawNodes.filter((n) => n.depth === 0 || (n.depth === undefined && !n.id.includes('.')));
3694
- const overrideMap = new Map(p.overrides.map((o) => [o.nodeId, o.newProbability]));
3695
- const oldConf = treeNodes.reduce((s, n) => s + (n.probability || 0) * (n.importance || 0), 0);
3696
- const newConf = treeNodes.reduce((s, n) => {
3697
- const prob = overrideMap.get(n.id) ?? n.probability ?? 0;
3698
- return s + prob * (n.importance || 0);
3699
- }, 0);
3700
- const nodeScales = new Map();
3701
- for (const [nid, np] of overrideMap.entries()) {
3702
- const nd = allNodes.find((n) => n.id === nid);
3703
- if (nd && nd.probability > 0)
3704
- nodeScales.set(nid, Math.max(0, Math.min(2, np / nd.probability)));
3705
- }
3706
- const edges = (ctx.edges || []).map((edge) => {
3707
- const relNode = edge.relatedNodeId;
3708
- let scaleFactor = 1;
3709
- if (relNode) {
3710
- const candidates = [relNode, relNode.split('.').slice(0, -1).join('.'), relNode.split('.')[0]].filter(Boolean);
3711
- for (const cid of candidates) {
3712
- if (nodeScales.has(cid)) {
3713
- scaleFactor = nodeScales.get(cid);
3714
- break;
3715
- }
3716
- }
3717
- }
3718
- const mkt = edge.marketPrice || 0;
3719
- const oldTP = edge.thesisPrice || edge.thesisImpliedPrice || mkt;
3720
- const oldEdge = edge.edge || edge.edgeSize || 0;
3721
- const newTP = Math.round((mkt + (oldTP - mkt) * scaleFactor) * 100) / 100;
3722
- const dir = edge.direction || 'yes';
3723
- const newEdge = Math.round((dir === 'yes' ? newTP - mkt : mkt - newTP) * 100) / 100;
3724
- return {
3725
- market: edge.market || edge.marketTitle || edge.marketId,
3726
- marketPrice: mkt,
3727
- oldEdge,
3728
- newEdge,
3729
- delta: newEdge - oldEdge,
3730
- signal: Math.abs(newEdge - oldEdge) < 1 ? 'unchanged' : (oldEdge > 0 && newEdge < 0) || (oldEdge < 0 && newEdge > 0) ? 'REVERSED' : Math.abs(newEdge) < 2 ? 'GONE' : 'reduced',
3731
- };
3732
- }).filter((e) => e.signal !== 'unchanged');
3733
- const result = {
3734
- overrides: p.overrides.map((o) => {
3735
- const node = allNodes.find((n) => n.id === o.nodeId);
3736
- return { nodeId: o.nodeId, label: node?.label || o.nodeId, oldProb: node?.probability, newProb: o.newProbability };
3737
- }),
3738
- confidence: { old: Math.round(oldConf * 100), new: Math.round(newConf * 100), delta: Math.round((newConf - oldConf) * 100) },
3739
- affectedEdges: edges,
3740
- };
3741
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
3742
- },
3743
- },
3744
- ];
3745
- // ── X (Twitter) tools for plain mode ──────────────────────────────────────
3746
- tools.push({
3747
- name: 'search_x', label: 'X Search',
3748
- description: 'Search X (Twitter) for recent discussions. Returns posts, sentiment, themes.',
3749
- parameters: Type.Object({
3750
- query: Type.String({ description: 'Search query' }),
3751
- mode: Type.Optional(Type.String({ description: '"summary" or "raw"' })),
3752
- hours: Type.Optional(Type.Number({ description: 'Hours (default 24)' })),
3753
- }),
3754
- execute: async (_id, p) => {
3755
- const data = await sfClient.searchX(p.query, { mode: p.mode, hours: p.hours });
3756
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3757
- },
3758
- }, {
3759
- name: 'x_volume', label: 'X Volume',
3760
- description: 'X discussion volume trend — total posts, velocity, peak time, timeseries.',
3761
- parameters: Type.Object({
3762
- query: Type.String({ description: 'Search query' }),
3763
- hours: Type.Optional(Type.Number({ description: 'Hours (default 72)' })),
3764
- }),
3765
- execute: async (_id, p) => {
3766
- const data = await sfClient.getXVolume(p.query, { hours: p.hours });
3767
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3768
- },
3769
- }, {
3770
- name: 'x_news', label: 'X News',
3771
- description: 'News stories trending on X — titles, summaries, tickers.',
3772
- parameters: Type.Object({
3773
- query: Type.String({ description: 'Search query' }),
3774
- limit: Type.Optional(Type.Number({ description: 'Max stories (default 10)' })),
3775
- }),
3776
- execute: async (_id, p) => {
3777
- const data = await sfClient.searchXNews(p.query, { limit: p.limit });
3778
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3779
- },
3780
- }, {
3781
- name: 'x_account', label: 'X Account',
3782
- description: 'Recent posts from a specific X account.',
3783
- parameters: Type.Object({
3784
- username: Type.String({ description: 'X username (with or without @)' }),
3785
- hours: Type.Optional(Type.Number({ description: 'Hours (default 24)' })),
3786
- }),
3787
- execute: async (_id, p) => {
3788
- const data = await sfClient.getXAccount(p.username, { hours: p.hours });
3789
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3790
- },
3791
- }, {
3792
- name: 'heartbeat_config', label: 'Heartbeat Config',
3793
- description: 'View or update heartbeat settings: scan intervals, model tier, budget cap, pause/resume. Shows cost breakdown.',
3794
- parameters: Type.Object({
3795
- thesisId: Type.String({ description: 'Thesis ID' }),
3796
- newsIntervalMin: Type.Optional(Type.Number({ description: 'News scan interval (15-1440 min)' })),
3797
- xIntervalMin: Type.Optional(Type.Number({ description: 'X scan interval (60-1440 min)' })),
3798
- evalModelTier: Type.Optional(Type.String({ description: 'cheap, medium, or heavy' })),
3799
- monthlyBudgetUsd: Type.Optional(Type.Number({ description: 'Monthly budget (0 = unlimited)' })),
3800
- paused: Type.Optional(Type.Boolean({ description: 'Pause or resume heartbeat' })),
3801
- }),
3802
- execute: async (_id, p) => {
3803
- const hasUp = p.newsIntervalMin || p.xIntervalMin || p.evalModelTier || p.monthlyBudgetUsd !== undefined || p.paused !== undefined;
3804
- if (hasUp) {
3805
- const u = {};
3806
- if (p.newsIntervalMin)
3807
- u.newsIntervalMin = p.newsIntervalMin;
3808
- if (p.xIntervalMin)
3809
- u.xIntervalMin = p.xIntervalMin;
3810
- if (p.evalModelTier)
3811
- u.evalModelTier = p.evalModelTier;
3812
- if (p.monthlyBudgetUsd !== undefined)
3813
- u.monthlyBudgetUsd = p.monthlyBudgetUsd;
3814
- if (p.paused !== undefined)
3815
- u.paused = p.paused;
3816
- await sfClient.updateHeartbeatConfig(p.thesisId, u);
3817
- }
3818
- const data = await sfClient.getHeartbeatConfig(p.thesisId);
3819
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
3820
- },
3821
- });
3822
- // ── Trading tools (conditional on tradingEnabled) for plain mode ──────────
3823
- const config = (0, config_js_1.loadConfig)();
3824
- if (config.tradingEnabled) {
3825
- tools.push({
3826
- name: 'place_order',
3827
- label: 'Place Order',
3828
- description: 'Place a buy or sell order on Kalshi. Shows preview and asks for confirmation.',
3829
- parameters: Type.Object({
3830
- ticker: Type.String({ description: 'Market ticker e.g. KXWTIMAX-26DEC31-T135' }),
3831
- side: Type.String({ description: 'yes or no' }),
3832
- action: Type.String({ description: 'buy or sell' }),
3833
- type: Type.String({ description: 'limit or market' }),
3834
- count: Type.Number({ description: 'Number of contracts' }),
3835
- price_cents: Type.Optional(Type.Number({ description: 'Limit price in cents (1-99). Required for limit orders.' })),
3836
- }),
3837
- execute: async (_id, p) => {
3838
- const { createOrder } = await import('../kalshi.js');
3839
- const readline = await import('readline');
3840
- const priceCents = p.price_cents ? Math.round(Number(p.price_cents)) : undefined;
3841
- const maxCost = ((priceCents || 99) * p.count / 100).toFixed(2);
3842
- const preview = ` Order: ${p.action.toUpperCase()} ${p.count}x ${p.ticker} ${p.side.toUpperCase()} @ ${priceCents ? priceCents + '\u00A2' : 'market'} (max $${maxCost})`;
3843
- process.stderr.write(preview + '\n');
3844
- // Reject if stdin is piped (non-TTY) — too dangerous to auto-execute trades
3845
- if (!process.stdin.isTTY) {
3846
- return { content: [{ type: 'text', text: 'Order rejected: trading requires interactive terminal (stdin is piped). Use TUI mode for trading.' }], details: {} };
3847
- }
3848
- // Confirm in plain mode via readline
3849
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
3850
- const answer = await new Promise(resolve => rl.question(' Execute? (y/n) ', resolve));
3851
- rl.close();
3852
- if (!answer.toLowerCase().startsWith('y')) {
3853
- return { content: [{ type: 'text', text: 'Order cancelled by user.' }], details: {} };
3854
- }
3855
- try {
3856
- const result = await createOrder({
3857
- ticker: p.ticker,
3858
- side: p.side,
3859
- action: p.action,
3860
- type: p.type,
3861
- count: p.count,
3862
- ...(priceCents ? { yes_price: priceCents } : {}),
3863
- });
3864
- const order = result.order || result;
3865
- return {
3866
- content: [{ type: 'text', text: `Order placed: ${order.order_id || 'OK'}\nStatus: ${order.status || '-'}\nFilled: ${order.fill_count_fp || 0}/${order.initial_count_fp || p.count}` }],
3867
- details: {},
3868
- };
3869
- }
3870
- catch (err) {
3871
- const msg = err.message || String(err);
3872
- if (msg.includes('403')) {
3873
- return { content: [{ type: 'text', text: '403 Forbidden \u2014 your Kalshi key lacks write permission. Get a read+write key at kalshi.com/account/api-keys' }], details: {} };
3874
- }
3875
- return { content: [{ type: 'text', text: `Order failed: ${msg}` }], details: {} };
3876
- }
3877
- },
3878
- }, {
3879
- name: 'cancel_order',
3880
- label: 'Cancel Order',
3881
- description: 'Cancel a resting order by order ID. Executes directly (no confirmation prompt in plain mode).',
3882
- parameters: Type.Object({
3883
- order_id: Type.String({ description: 'Order ID to cancel' }),
3884
- }),
3885
- execute: async (_id, p) => {
3886
- const { cancelOrder } = await import('../kalshi.js');
3887
- try {
3888
- await cancelOrder(p.order_id);
3889
- return { content: [{ type: 'text', text: `Order ${p.order_id} cancelled.` }], details: {} };
3890
- }
3891
- catch (err) {
3892
- return { content: [{ type: 'text', text: `Cancel failed: ${err.message}` }], details: {} };
3893
- }
3894
- },
3895
- });
3896
- }
3897
- // ── System prompt ─────────────────────────────────────────────────────────
3898
- const ctx = latestContext;
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.
3942
-
3943
- ## Framework
3944
- Edge = thesis price - market price. Positive = market underprices. executableEdge = edge minus spread.
3945
-
3946
- Edge types: "consensus_gap" (depth >= 500, real disagreement), "attention_gap" (depth < 100, illusory pricing), "timing_gap" (market lags), "risk_premium" (settlement/platform risk). Always classify when reporting edges.
3947
- For edges > 20 cents, state what the market must believe for it to be right.
3948
-
3949
- Price reliability: depth >= 500 = consensus. depth < 100 = unreliable. spread > 5 cents = noisy. Flag illiquid markets.
3950
-
3951
- Kill conditions: each causal node has a falsifier. Check these first when evaluating news. If triggered, override other analysis.
3952
-
3953
- Always state contract expiry and next catalyst. No catalyst = flag capital lock risk.
3954
-
3955
- For complex questions, chain: get_context -> inspect_book -> get_liquidity -> web_search -> search_x -> synthesize.
3956
- Use search_x for social sentiment on any topic. Use x_volume to detect discussion spikes. Use x_account to track key people.
3957
- Use heartbeat_config to view/adjust scan intervals, model tier, budget cap, or check cost breakdown.
3958
-
3959
- Flag correlated exposure across positions sharing upstream nodes. If nothing to do, say so.
3960
-
3961
- ## Rules
3962
- - Be concise. Use tools for fresh data. Don't guess prices.
3963
- - You do NOT know the user's positions at start. Call get_positions before discussing trades or portfolio.
3964
- - If user mentions news, inject_signal immediately.
3965
- - If user says "evaluate", trigger immediately. Don't confirm.
3966
- - Don't end with "anything else?"
3967
- - If an edge is narrowing or disappearing, say so proactively.
3968
- - Use Chinese if user writes Chinese, English if English.
3969
- - Prices in cents (¢). P&L in dollars ($). Don't re-convert tool output.
3970
- - When a trade idea emerges, create_strategy to record it.
3971
- ${config.tradingEnabled ? '- Trading ENABLED. You have place_order and cancel_order tools.' : '- Trading DISABLED. Tell user: sf setup --enable-trading'}
3972
-
3973
- ## Current State
3974
- Thesis: ${ctx.thesis || ctx.rawThesis || 'N/A'}
3975
- ID: ${resolvedThesisId} | Confidence: ${conf}% | Status: ${ctx.status}
3976
-
3977
- Causal nodes:
3978
- ${nodesSummary}
3979
-
3980
- Top edges:
3981
- ${edgesSummary}
3982
-
3983
- ${ctx.lastEvaluation?.summary ? `Latest eval: ${ctx.lastEvaluation.summary.slice(0, 300)}` : ''}`;
3984
- }
3985
- // ── Create agent ──────────────────────────────────────────────────────────
3986
- const agent = new Agent({
3987
- initialState: { systemPrompt, model, tools, thinkingLevel: 'off' },
3988
- streamFn: streamSimple,
3989
- getApiKey: (provider) => provider === 'openrouter' ? openrouterKey : undefined,
3990
- });
3991
- // ── Session restore ───────────────────────────────────────────────────────
3992
- if (!opts?.newSession) {
3993
- const saved = loadSession(resolvedThesisId);
3994
- if (saved?.messages?.length > 0) {
3995
- try {
3996
- agent.replaceMessages(saved.messages);
3997
- agent.setSystemPrompt(systemPrompt);
3998
- }
3999
- catch { /* start fresh */ }
4000
- }
4001
- }
4002
- // ── Subscribe to agent events → plain stdout ──────────────────────────────
4003
- let currentText = '';
4004
- agent.subscribe((event) => {
4005
- if (event.type === 'message_update') {
4006
- const e = event.assistantMessageEvent;
4007
- if (e.type === 'text_delta') {
4008
- process.stdout.write(e.delta);
4009
- currentText += e.delta;
4010
- }
4011
- }
4012
- if (event.type === 'message_end') {
4013
- if (currentText) {
4014
- process.stdout.write('\n');
4015
- currentText = '';
4016
- }
4017
- }
4018
- if (event.type === 'tool_execution_start') {
4019
- process.stderr.write(` \u25B8 ${event.toolName}...\n`);
4020
- }
4021
- if (event.type === 'tool_execution_end') {
4022
- const status = event.isError ? '\u2717' : '\u2713';
4023
- process.stderr.write(` ${status} ${event.toolName}\n`);
4024
- }
4025
- });
4026
- // ── Welcome ───────────────────────────────────────────────────────────────
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
- }
4040
- // ── REPL loop ─────────────────────────────────────────────────────────────
4041
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' });
4042
- rl.prompt();
4043
- for await (const line of rl) {
4044
- const trimmed = line.trim();
4045
- if (!trimmed) {
4046
- rl.prompt();
4047
- continue;
4048
- }
4049
- if (trimmed === '/exit' || trimmed === '/quit') {
4050
- try {
4051
- saveSession(resolvedThesisId, currentModelName, agent.state.messages);
4052
- }
4053
- catch { }
4054
- rl.close();
4055
- return;
4056
- }
4057
- if (trimmed === '/help') {
4058
- console.log('Commands: /help /exit /tree /edges /eval /model <name>');
4059
- rl.prompt();
4060
- continue;
4061
- }
4062
- if (trimmed === '/tree') {
4063
- latestContext = await sfClient.getContext(resolvedThesisId);
4064
- const nodes = latestContext.causalTree?.nodes || [];
4065
- for (const n of nodes) {
4066
- const indent = ' '.repeat(n.depth || 0);
4067
- console.log(`${indent}${n.id} ${(n.label || '').slice(0, 60)} — ${Math.round(n.probability * 100)}%`);
4068
- }
4069
- rl.prompt();
4070
- continue;
4071
- }
4072
- if (trimmed === '/edges') {
4073
- latestContext = await sfClient.getContext(resolvedThesisId);
4074
- const edges = (latestContext.edges || []).sort((a, b) => Math.abs(b.edge) - Math.abs(a.edge)).slice(0, 15);
4075
- for (const e of edges) {
4076
- const sign = e.edge > 0 ? '+' : '';
4077
- console.log(` ${(e.market || '').slice(0, 45).padEnd(45)} ${e.marketPrice}¢ edge ${sign}${e.edge} ${e.venue}`);
4078
- }
4079
- rl.prompt();
4080
- continue;
4081
- }
4082
- if (trimmed === '/eval') {
4083
- console.log('Triggering evaluation...');
4084
- const result = await sfClient.evaluate(resolvedThesisId);
4085
- console.log(`Confidence: ${result.previousConfidence} → ${result.newConfidence}`);
4086
- if (result.summary)
4087
- console.log(result.summary);
4088
- rl.prompt();
4089
- continue;
4090
- }
4091
- if (trimmed.startsWith('/model')) {
4092
- const newModel = trimmed.slice(6).trim();
4093
- if (!newModel) {
4094
- console.log(`Current: ${currentModelName}`);
4095
- rl.prompt();
4096
- continue;
4097
- }
4098
- currentModelName = newModel.replace(/^openrouter\//, '');
4099
- model = resolveModel(currentModelName);
4100
- agent.setModel(model);
4101
- console.log(`Model: ${currentModelName}`);
4102
- rl.prompt();
4103
- continue;
4104
- }
4105
- // Regular message → agent
4106
- try {
4107
- await agent.prompt(trimmed);
4108
- }
4109
- catch (err) {
4110
- console.error(`Error: ${err.message}`);
4111
- }
4112
- // Save after each turn
4113
- try {
4114
- saveSession(resolvedThesisId, currentModelName, agent.state.messages);
4115
- }
4116
- catch { }
4117
- rl.prompt();
4118
- }
4119
- }