@spfunctions/cli 1.4.4 → 1.4.5

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 (71) hide show
  1. package/README.md +205 -48
  2. package/dist/cache.d.ts +6 -0
  3. package/dist/cache.js +31 -0
  4. package/dist/cache.test.d.ts +1 -0
  5. package/dist/cache.test.js +73 -0
  6. package/dist/client.test.d.ts +1 -0
  7. package/dist/client.test.js +89 -0
  8. package/dist/commands/agent.js +245 -67
  9. package/dist/commands/dashboard.d.ts +6 -3
  10. package/dist/commands/dashboard.js +28 -26
  11. package/dist/commands/performance.js +9 -2
  12. package/dist/commands/telegram.d.ts +15 -0
  13. package/dist/commands/telegram.js +125 -0
  14. package/dist/config.d.ts +1 -0
  15. package/dist/config.js +1 -0
  16. package/dist/config.test.d.ts +1 -0
  17. package/dist/config.test.js +138 -0
  18. package/dist/index.js +16 -2
  19. package/dist/telegram/agent-bridge.d.ts +15 -0
  20. package/dist/telegram/agent-bridge.js +368 -0
  21. package/dist/telegram/bot.d.ts +10 -0
  22. package/dist/telegram/bot.js +297 -0
  23. package/dist/telegram/commands.d.ts +11 -0
  24. package/dist/telegram/commands.js +120 -0
  25. package/dist/telegram/format.d.ts +11 -0
  26. package/dist/telegram/format.js +51 -0
  27. package/dist/telegram/format.test.d.ts +1 -0
  28. package/dist/telegram/format.test.js +73 -0
  29. package/dist/telegram/poller.d.ts +6 -0
  30. package/dist/telegram/poller.js +32 -0
  31. package/dist/topics.test.d.ts +1 -0
  32. package/dist/topics.test.js +54 -0
  33. package/dist/tui/border.d.ts +33 -0
  34. package/dist/tui/border.js +87 -0
  35. package/dist/tui/chart.d.ts +19 -0
  36. package/dist/tui/chart.js +117 -0
  37. package/dist/tui/dashboard.d.ts +9 -0
  38. package/dist/tui/dashboard.js +779 -0
  39. package/dist/tui/layout.d.ts +16 -0
  40. package/dist/tui/layout.js +41 -0
  41. package/dist/tui/screen.d.ts +33 -0
  42. package/dist/tui/screen.js +102 -0
  43. package/dist/tui/state.d.ts +40 -0
  44. package/dist/tui/state.js +36 -0
  45. package/dist/tui/widgets/commandbar.d.ts +8 -0
  46. package/dist/tui/widgets/commandbar.js +82 -0
  47. package/dist/tui/widgets/detail.d.ts +9 -0
  48. package/dist/tui/widgets/detail.js +151 -0
  49. package/dist/tui/widgets/edges.d.ts +4 -0
  50. package/dist/tui/widgets/edges.js +33 -0
  51. package/dist/tui/widgets/liquidity.d.ts +9 -0
  52. package/dist/tui/widgets/liquidity.js +142 -0
  53. package/dist/tui/widgets/orders.d.ts +4 -0
  54. package/dist/tui/widgets/orders.js +37 -0
  55. package/dist/tui/widgets/portfolio.d.ts +4 -0
  56. package/dist/tui/widgets/portfolio.js +58 -0
  57. package/dist/tui/widgets/signals.d.ts +4 -0
  58. package/dist/tui/widgets/signals.js +31 -0
  59. package/dist/tui/widgets/statusbar.d.ts +8 -0
  60. package/dist/tui/widgets/statusbar.js +72 -0
  61. package/dist/tui/widgets/thesis.d.ts +4 -0
  62. package/dist/tui/widgets/thesis.js +66 -0
  63. package/dist/tui/widgets/trade.d.ts +9 -0
  64. package/dist/tui/widgets/trade.js +117 -0
  65. package/dist/tui/widgets/upcoming.d.ts +4 -0
  66. package/dist/tui/widgets/upcoming.js +41 -0
  67. package/dist/tui/widgets/whatif.d.ts +7 -0
  68. package/dist/tui/widgets/whatif.js +113 -0
  69. package/dist/utils.test.d.ts +1 -0
  70. package/dist/utils.test.js +111 -0
  71. package/package.json +6 -2
@@ -0,0 +1,368 @@
1
+ "use strict";
2
+ /**
3
+ * Agent bridge — connects Telegram to pi-agent-core
4
+ *
5
+ * Uses the SAME tools as sf agent --plain. Multi-turn tool calling
6
+ * is handled by pi-agent-core's Agent class (not manual OpenRouter calls).
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.getOrCreateAgent = getOrCreateAgent;
10
+ exports.runAgentMessage = runAgentMessage;
11
+ const config_js_1 = require("../config.js");
12
+ let piModules = null;
13
+ async function loadPiModules() {
14
+ if (piModules)
15
+ return piModules;
16
+ const [piAgent, piAi] = await Promise.all([
17
+ import('@mariozechner/pi-agent-core'),
18
+ import('@mariozechner/pi-ai'),
19
+ ]);
20
+ const typebox = await import('@sinclair/typebox');
21
+ piModules = { Agent: piAgent.Agent, streamSimple: piAi.streamSimple, Type: typebox.Type };
22
+ return piModules;
23
+ }
24
+ async function buildTools(sfClient, thesisId, latestContext) {
25
+ const { Type } = await loadPiModules();
26
+ const config = (0, config_js_1.loadConfig)();
27
+ const emptyParams = Type.Object({});
28
+ // Import Kalshi functions
29
+ const kalshi = await import('../kalshi.js');
30
+ const { kalshiFetchAllSeries, kalshiFetchMarketsBySeries, kalshiFetchMarket } = await import('../client.js');
31
+ const tools = [
32
+ {
33
+ name: 'get_context', label: 'Context',
34
+ description: 'Get thesis snapshot with causal tree, edges, evaluation',
35
+ parameters: emptyParams,
36
+ execute: async () => {
37
+ const ctx = await sfClient.getContext(thesisId);
38
+ return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }], details: {} };
39
+ },
40
+ },
41
+ {
42
+ name: 'inject_signal', label: 'Signal',
43
+ description: 'Inject a signal (news, note, observation) into the thesis',
44
+ parameters: Type.Object({
45
+ content: Type.String({ description: 'Signal content' }),
46
+ type: Type.Optional(Type.String({ description: 'news | user_note | external' })),
47
+ }),
48
+ execute: async (_id, p) => {
49
+ await sfClient.injectSignal(thesisId, p.type || 'user_note', p.content, 'telegram');
50
+ return { content: [{ type: 'text', text: 'Signal injected.' }], details: {} };
51
+ },
52
+ },
53
+ {
54
+ name: 'trigger_evaluation', label: 'Evaluate',
55
+ description: 'Trigger a deep evaluation cycle',
56
+ parameters: emptyParams,
57
+ execute: async () => {
58
+ await sfClient.evaluate(thesisId);
59
+ return { content: [{ type: 'text', text: 'Evaluation triggered. Results in ~2 minutes.' }], details: {} };
60
+ },
61
+ },
62
+ {
63
+ name: 'scan_markets', label: 'Scan',
64
+ description: 'Search Kalshi markets by keyword, series, or ticker',
65
+ parameters: Type.Object({
66
+ query: Type.Optional(Type.String({ description: 'Keyword search' })),
67
+ series: Type.Optional(Type.String({ description: 'Series ticker' })),
68
+ market: Type.Optional(Type.String({ description: 'Market ticker' })),
69
+ }),
70
+ execute: async (_id, p) => {
71
+ let result;
72
+ if (p.market) {
73
+ result = await kalshiFetchMarket(p.market);
74
+ }
75
+ else if (p.series) {
76
+ result = await kalshiFetchMarketsBySeries(p.series);
77
+ }
78
+ else if (p.query) {
79
+ const series = await kalshiFetchAllSeries();
80
+ const kws = p.query.toLowerCase().split(/\s+/);
81
+ result = series.filter((s) => kws.every((k) => ((s.title || '') + (s.ticker || '')).toLowerCase().includes(k)))
82
+ .filter((s) => parseFloat(s.volume_fp || '0') > 1000)
83
+ .sort((a, b) => parseFloat(b.volume_fp || '0') - parseFloat(a.volume_fp || '0'))
84
+ .slice(0, 10);
85
+ }
86
+ else {
87
+ result = { error: 'Provide query, series, or market' };
88
+ }
89
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], details: {} };
90
+ },
91
+ },
92
+ {
93
+ name: 'list_theses', label: 'List',
94
+ description: 'List all theses',
95
+ parameters: emptyParams,
96
+ execute: async () => {
97
+ const theses = await sfClient.listTheses();
98
+ return { content: [{ type: 'text', text: JSON.stringify(theses, null, 2) }], details: {} };
99
+ },
100
+ },
101
+ {
102
+ name: 'get_positions', label: 'Positions',
103
+ description: 'Get Kalshi positions with live prices and P&L',
104
+ parameters: emptyParams,
105
+ execute: async () => {
106
+ const positions = await kalshi.getPositions();
107
+ if (!positions)
108
+ return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
109
+ for (const pos of positions) {
110
+ const price = await kalshi.getMarketPrice(pos.ticker);
111
+ if (price != null) {
112
+ pos.current_value = price;
113
+ pos.unrealized_pnl = Math.round((price - pos.average_price_paid) * pos.quantity);
114
+ }
115
+ }
116
+ const formatted = positions.map((p) => ({
117
+ ticker: p.ticker, qty: p.quantity,
118
+ avg_price: `${p.average_price_paid}¢`, current: `${p.current_value}¢`,
119
+ pnl: `$${((p.unrealized_pnl || 0) / 100).toFixed(2)}`,
120
+ }));
121
+ return { content: [{ type: 'text', text: JSON.stringify(formatted, null, 2) }], details: {} };
122
+ },
123
+ },
124
+ {
125
+ name: 'get_balance', label: 'Balance',
126
+ description: 'Get Kalshi account balance',
127
+ parameters: emptyParams,
128
+ execute: async () => {
129
+ const bal = await kalshi.getBalance();
130
+ if (!bal)
131
+ return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
132
+ return { content: [{ type: 'text', text: JSON.stringify(bal, null, 2) }], details: {} };
133
+ },
134
+ },
135
+ {
136
+ name: 'get_orders', label: 'Orders',
137
+ description: 'Get resting orders on Kalshi',
138
+ parameters: emptyParams,
139
+ execute: async () => {
140
+ const result = await kalshi.getOrders({ status: 'resting', limit: 50 });
141
+ if (!result)
142
+ return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
143
+ return { content: [{ type: 'text', text: JSON.stringify(result.orders, null, 2) }], details: {} };
144
+ },
145
+ },
146
+ {
147
+ name: 'get_fills', label: 'Fills',
148
+ description: 'Get recent trade fills',
149
+ parameters: Type.Object({ ticker: Type.Optional(Type.String()) }),
150
+ execute: async (_id, p) => {
151
+ const result = await kalshi.getFills({ ticker: p.ticker, limit: 20 });
152
+ if (!result)
153
+ return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
154
+ return { content: [{ type: 'text', text: JSON.stringify(result.fills, null, 2) }], details: {} };
155
+ },
156
+ },
157
+ {
158
+ name: 'get_settlements', label: 'Settlements',
159
+ description: 'Get settled contracts with P&L',
160
+ parameters: emptyParams,
161
+ execute: async () => {
162
+ const result = await kalshi.getSettlements({ limit: 50 });
163
+ if (!result)
164
+ return { content: [{ type: 'text', text: 'Kalshi not configured.' }], details: {} };
165
+ return { content: [{ type: 'text', text: JSON.stringify(result.settlements, null, 2) }], details: {} };
166
+ },
167
+ },
168
+ {
169
+ name: 'get_edges', label: 'Edges',
170
+ description: 'Top 10 edges across all active theses',
171
+ parameters: emptyParams,
172
+ execute: async () => {
173
+ const { theses } = await sfClient.listTheses();
174
+ const active = (theses || []).filter((t) => t.status === 'active');
175
+ const results = await Promise.allSettled(active.map(async (t) => {
176
+ const ctx = await sfClient.getContext(t.id);
177
+ return (ctx.edges || []).map((e) => ({ ...e, thesisId: t.id }));
178
+ }));
179
+ const allEdges = [];
180
+ for (const r of results) {
181
+ if (r.status === 'fulfilled')
182
+ allEdges.push(...r.value);
183
+ }
184
+ allEdges.sort((a, b) => Math.abs(b.edge || 0) - Math.abs(a.edge || 0));
185
+ return { content: [{ type: 'text', text: JSON.stringify(allEdges.slice(0, 10), null, 2) }], details: {} };
186
+ },
187
+ },
188
+ {
189
+ name: 'get_schedule', label: 'Schedule',
190
+ description: 'Exchange status (open/closed) and trading hours',
191
+ parameters: emptyParams,
192
+ execute: async () => {
193
+ const res = await fetch('https://api.elections.kalshi.com/trade-api/v2/exchange/status', { headers: { 'Accept': 'application/json' } });
194
+ if (!res.ok)
195
+ return { content: [{ type: 'text', text: `API error: ${res.status}` }], details: {} };
196
+ const data = await res.json();
197
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
198
+ },
199
+ },
200
+ {
201
+ name: 'get_feed', label: 'Feed',
202
+ description: 'Recent evaluation history',
203
+ parameters: Type.Object({ hours: Type.Optional(Type.Number({ description: 'Hours of history (default 24)' })) }),
204
+ execute: async (_id, p) => {
205
+ const data = await sfClient.getFeed(p.hours || 24);
206
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], details: {} };
207
+ },
208
+ },
209
+ {
210
+ name: 'web_search', label: 'Search',
211
+ description: 'Search the web for latest news',
212
+ parameters: Type.Object({ query: Type.String({ description: 'Search query' }) }),
213
+ execute: async (_id, p) => {
214
+ const tavilyKey = process.env.TAVILY_API_KEY || config.tavilyKey;
215
+ if (!tavilyKey)
216
+ return { content: [{ type: 'text', text: 'Tavily not configured.' }], details: {} };
217
+ const res = await fetch('https://api.tavily.com/search', {
218
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
219
+ body: JSON.stringify({ api_key: tavilyKey, query: p.query, max_results: 3, search_depth: 'basic', include_answer: true }),
220
+ });
221
+ if (!res.ok)
222
+ return { content: [{ type: 'text', text: `Search failed: ${res.status}` }], details: {} };
223
+ const data = await res.json();
224
+ const answer = data.answer ? `Summary: ${data.answer}\n\n` : '';
225
+ const results = (data.results || []).map((r) => `${r.title}: ${(r.content || '').slice(0, 150)}`).join('\n\n');
226
+ return { content: [{ type: 'text', text: `${answer}${results}` }], details: {} };
227
+ },
228
+ },
229
+ {
230
+ name: 'create_thesis', label: 'Create',
231
+ description: 'Create a new thesis',
232
+ parameters: Type.Object({ rawThesis: Type.String({ description: 'Thesis statement' }) }),
233
+ execute: async (_id, p) => {
234
+ const result = await sfClient.createThesis(p.rawThesis, true);
235
+ return { content: [{ type: 'text', text: `Created: ${result.id}\nConfidence: ${Math.round((result.confidence || 0.5) * 100)}%` }], details: {} };
236
+ },
237
+ },
238
+ {
239
+ name: 'what_if', label: 'What-If',
240
+ description: 'Override causal tree node probabilities and see how edges change. Zero LLM cost.',
241
+ parameters: Type.Object({
242
+ overrides: Type.Array(Type.Object({
243
+ nodeId: Type.String({ description: 'Node ID (e.g. n1, n3.1)' }),
244
+ newProbability: Type.Number({ description: 'New probability 0-1' }),
245
+ })),
246
+ }),
247
+ execute: async (_id, p) => {
248
+ const ctx = latestContext;
249
+ const allNodes = [];
250
+ function flatten(nodes) { for (const n of nodes) {
251
+ allNodes.push(n);
252
+ if (n.children?.length)
253
+ flatten(n.children);
254
+ } }
255
+ flatten(ctx?.causalTree?.nodes || []);
256
+ const overrideMap = new Map(p.overrides.map((o) => [o.nodeId, o.newProbability]));
257
+ const topNodes = (ctx?.causalTree?.nodes || []).filter((n) => !n.parentId || n.depth === 0);
258
+ const oldConf = topNodes.reduce((s, n) => s + (n.probability || 0.5), 0) / Math.max(topNodes.length, 1);
259
+ for (const n of allNodes) {
260
+ if (overrideMap.has(n.id))
261
+ n.probability = overrideMap.get(n.id);
262
+ }
263
+ const newConf = topNodes.reduce((s, n) => s + (n.probability || 0.5), 0) / Math.max(topNodes.length, 1);
264
+ const edges = (ctx?.edges || []).slice(0, 10).map((e) => {
265
+ const node = allNodes.find((n) => n.id === e.relatedNodeId);
266
+ if (!node || !overrideMap.has(node.id))
267
+ return { market: e.market, oldEdge: e.edge, newEdge: e.edge, signal: 'unchanged' };
268
+ const scale = node.probability / (e.confidence || 0.5);
269
+ const newThesis = Math.round(e.thesisPrice * scale);
270
+ const newEdge = newThesis - e.marketPrice;
271
+ return { market: e.market, oldEdge: e.edge, newEdge, signal: Math.abs(newEdge - e.edge) < 1 ? 'unchanged' : 'changed' };
272
+ }).filter((e) => e.signal !== 'unchanged');
273
+ return { content: [{ type: 'text', text: JSON.stringify({ confidence: { old: Math.round(oldConf * 100), new: Math.round(newConf * 100) }, edges }, null, 2) }], details: {} };
274
+ },
275
+ },
276
+ ];
277
+ return tools;
278
+ }
279
+ async function getOrCreateAgent(sfClient, session) {
280
+ if (session.agent)
281
+ return session.agent;
282
+ const { Agent, streamSimple } = await loadPiModules();
283
+ const piAi = await import('@mariozechner/pi-ai');
284
+ const { getModel } = piAi;
285
+ const config = (0, config_js_1.loadConfig)();
286
+ const openrouterKey = config.openrouterKey || process.env.OPENROUTER_API_KEY;
287
+ if (!openrouterKey)
288
+ throw new Error('OpenRouter not configured. Use slash commands or run sf setup.');
289
+ const ctx = await sfClient.getContext(session.thesisId);
290
+ const conf = typeof ctx.confidence === 'number' ? Math.round(ctx.confidence * 100) : 50;
291
+ const tools = await buildTools(sfClient, session.thesisId, ctx);
292
+ // Resolve model object (not just a string — pi-ai needs api/provider/baseUrl)
293
+ const modelName = config.model || 'anthropic/claude-sonnet-4.6';
294
+ let model;
295
+ try {
296
+ model = getModel('openrouter', modelName);
297
+ }
298
+ catch {
299
+ model = {
300
+ modelId: modelName, provider: 'openrouter', api: 'openai-completions',
301
+ baseUrl: 'https://openrouter.ai/api/v1', id: modelName, name: modelName,
302
+ inputPrice: 0, outputPrice: 0, contextWindow: 200000,
303
+ supportsImages: true, supportsTools: true,
304
+ };
305
+ }
306
+ const systemPrompt = `You are a prediction market trading assistant on Telegram. Be concise.
307
+
308
+ Thesis: ${(ctx.thesis || ctx.rawThesis || 'N/A').slice(0, 200)}
309
+ ID: ${session.thesisId.slice(0, 8)}
310
+ Confidence: ${conf}%
311
+
312
+ Rules:
313
+ - Keep responses short — Telegram messages should be brief.
314
+ - Prices are in cents (¢). P&L in dollars ($). Tool outputs are pre-formatted.
315
+ - Use Chinese if user writes Chinese, English if English.
316
+ - Call tools when needed. Don't guess data.`;
317
+ const agent = new Agent({
318
+ initialState: {
319
+ systemPrompt,
320
+ model,
321
+ tools,
322
+ thinkingLevel: 'off',
323
+ },
324
+ streamFn: streamSimple,
325
+ getApiKey: (provider) => provider === 'openrouter' ? openrouterKey : undefined,
326
+ });
327
+ // Restore session messages if available
328
+ if (session.agentMessages.length > 0) {
329
+ try {
330
+ agent.replaceMessages(session.agentMessages);
331
+ }
332
+ catch { /* start fresh */ }
333
+ }
334
+ session.agent = agent;
335
+ return agent;
336
+ }
337
+ async function runAgentMessage(client, session, userMessage) {
338
+ const agent = await getOrCreateAgent(client, session);
339
+ return new Promise((resolve, reject) => {
340
+ let response = '';
341
+ let toolLog = '';
342
+ const timeout = setTimeout(() => {
343
+ resolve(response || toolLog || 'Response timeout (45s). Try a simpler question.');
344
+ }, 45_000);
345
+ const unsub = agent.subscribe((event) => {
346
+ if (event.type === 'message_update') {
347
+ const e = event.assistantMessageEvent;
348
+ if (e.type === 'text_delta')
349
+ response += e.delta;
350
+ }
351
+ if (event.type === 'tool_execution_start') {
352
+ toolLog += `⚡ ${event.toolName}\n`;
353
+ }
354
+ if (event.type === 'agent_end') {
355
+ clearTimeout(timeout);
356
+ unsub?.();
357
+ // Save messages for session continuity
358
+ session.agentMessages = agent.state.messages || [];
359
+ resolve(response || toolLog || '(no response)');
360
+ }
361
+ });
362
+ agent.prompt(userMessage).catch((err) => {
363
+ clearTimeout(timeout);
364
+ unsub?.();
365
+ reject(err);
366
+ });
367
+ });
368
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Telegram Bot — main entry point
3
+ *
4
+ * Handles slash commands (zero LLM) and free text (via pi-agent-core).
5
+ * Polls delta API for push notifications.
6
+ */
7
+ export declare function startBot(opts: {
8
+ token?: string;
9
+ chatId?: number;
10
+ }): Promise<void>;
@@ -0,0 +1,297 @@
1
+ "use strict";
2
+ /**
3
+ * Telegram Bot — main entry point
4
+ *
5
+ * Handles slash commands (zero LLM) and free text (via pi-agent-core).
6
+ * Polls delta API for push notifications.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.startBot = startBot;
10
+ const grammy_1 = require("grammy");
11
+ const client_js_1 = require("../client.js");
12
+ const config_js_1 = require("../config.js");
13
+ const format_js_1 = require("./format.js");
14
+ const commands_js_1 = require("./commands.js");
15
+ const poller_js_1 = require("./poller.js");
16
+ const sessions = new Map();
17
+ function getSession(chatId) {
18
+ if (!sessions.has(chatId)) {
19
+ sessions.set(chatId, { thesisId: null, agentMessages: [] });
20
+ }
21
+ return sessions.get(chatId);
22
+ }
23
+ async function startBot(opts) {
24
+ const config = (0, config_js_1.loadConfig)();
25
+ const botToken = opts.token || config.telegramBotToken || process.env.TELEGRAM_BOT_TOKEN;
26
+ if (!botToken) {
27
+ console.error('No Telegram bot token. Use --token, set TELEGRAM_BOT_TOKEN, or add to ~/.sf/config.json');
28
+ process.exit(1);
29
+ }
30
+ const client = new client_js_1.SFClient(config.apiKey, config.apiUrl);
31
+ const bot = new grammy_1.Bot(botToken);
32
+ const allowedChat = opts.chatId || null;
33
+ const pollers = new Map();
34
+ // Auth guard
35
+ function isAllowed(chatId) {
36
+ return !allowedChat || chatId === allowedChat;
37
+ }
38
+ // ── /start ──
39
+ bot.command('start', async (ctx) => {
40
+ if (!isAllowed(ctx.chat.id))
41
+ return;
42
+ const session = getSession(ctx.chat.id);
43
+ // Auto-detect first active thesis
44
+ try {
45
+ const { theses } = await client.listTheses();
46
+ const active = (theses || []).filter((t) => t.status === 'active');
47
+ if (active.length > 0) {
48
+ session.thesisId = active[0].id;
49
+ await ctx.reply(`✅ Connected to SimpleFunctions\n\n` +
50
+ `Active thesis: <b>${(active[0].rawThesis || '').slice(0, 60)}</b>\n` +
51
+ `ID: <code>${active[0].id.slice(0, 8)}</code>\n\n` +
52
+ `Commands:\n` +
53
+ `/context — thesis snapshot\n` +
54
+ `/positions — Kalshi positions\n` +
55
+ `/edges — top edges\n` +
56
+ `/balance — account balance\n` +
57
+ `/orders — resting orders\n` +
58
+ `/eval — trigger evaluation\n` +
59
+ `/list — all theses\n` +
60
+ `/switch — switch thesis\n\n` +
61
+ `Or just type naturally.`, { parse_mode: 'HTML' });
62
+ // Start polling for this chat
63
+ if (pollers.has(ctx.chat.id))
64
+ clearInterval(pollers.get(ctx.chat.id));
65
+ pollers.set(ctx.chat.id, (0, poller_js_1.startPoller)(bot, ctx.chat.id, client, session.thesisId));
66
+ }
67
+ else {
68
+ await ctx.reply('No active theses found. Create one with `sf create "your thesis"`');
69
+ }
70
+ }
71
+ catch (err) {
72
+ await ctx.reply(`❌ Connection failed: ${err.message}\nCheck SF_API_KEY in ~/.sf/config.json`);
73
+ }
74
+ });
75
+ // ── /context ──
76
+ bot.command('context', async (ctx) => {
77
+ if (!isAllowed(ctx.chat.id))
78
+ return;
79
+ const session = getSession(ctx.chat.id);
80
+ if (!session.thesisId) {
81
+ await ctx.reply('No thesis selected. Use /start or /switch <id>');
82
+ return;
83
+ }
84
+ try {
85
+ const text = await (0, commands_js_1.handleContext)(client, session.thesisId);
86
+ for (const chunk of (0, format_js_1.splitMessage)(text))
87
+ await ctx.reply(chunk, { parse_mode: 'HTML' });
88
+ }
89
+ catch (err) {
90
+ await ctx.reply(`❌ ${err.message}`);
91
+ }
92
+ });
93
+ // ── /positions ──
94
+ bot.command('positions', async (ctx) => {
95
+ if (!isAllowed(ctx.chat.id))
96
+ return;
97
+ try {
98
+ const text = await (0, commands_js_1.handlePositions)();
99
+ for (const chunk of (0, format_js_1.splitMessage)(text))
100
+ await ctx.reply(chunk, { parse_mode: 'HTML' });
101
+ }
102
+ catch (err) {
103
+ await ctx.reply(`❌ ${err.message}`);
104
+ }
105
+ });
106
+ // ── /edges ──
107
+ bot.command('edges', async (ctx) => {
108
+ if (!isAllowed(ctx.chat.id))
109
+ return;
110
+ try {
111
+ const text = await (0, commands_js_1.handleEdges)(client);
112
+ for (const chunk of (0, format_js_1.splitMessage)(text))
113
+ await ctx.reply(chunk, { parse_mode: 'HTML' });
114
+ }
115
+ catch (err) {
116
+ await ctx.reply(`❌ ${err.message}`);
117
+ }
118
+ });
119
+ // ── /balance ──
120
+ bot.command('balance', async (ctx) => {
121
+ if (!isAllowed(ctx.chat.id))
122
+ return;
123
+ try {
124
+ const text = await (0, commands_js_1.handleBalance)();
125
+ await ctx.reply(text, { parse_mode: 'HTML' });
126
+ }
127
+ catch (err) {
128
+ await ctx.reply(`❌ ${err.message}`);
129
+ }
130
+ });
131
+ // ── /orders ──
132
+ bot.command('orders', async (ctx) => {
133
+ if (!isAllowed(ctx.chat.id))
134
+ return;
135
+ try {
136
+ const text = await (0, commands_js_1.handleOrders)();
137
+ for (const chunk of (0, format_js_1.splitMessage)(text))
138
+ await ctx.reply(chunk, { parse_mode: 'HTML' });
139
+ }
140
+ catch (err) {
141
+ await ctx.reply(`❌ ${err.message}`);
142
+ }
143
+ });
144
+ // ── /eval ──
145
+ bot.command('eval', async (ctx) => {
146
+ if (!isAllowed(ctx.chat.id))
147
+ return;
148
+ const session = getSession(ctx.chat.id);
149
+ if (!session.thesisId) {
150
+ await ctx.reply('No thesis selected.');
151
+ return;
152
+ }
153
+ try {
154
+ const text = await (0, commands_js_1.handleEval)(client, session.thesisId);
155
+ await ctx.reply(text);
156
+ }
157
+ catch (err) {
158
+ await ctx.reply(`❌ ${err.message}`);
159
+ }
160
+ });
161
+ // ── /list ──
162
+ bot.command('list', async (ctx) => {
163
+ if (!isAllowed(ctx.chat.id))
164
+ return;
165
+ try {
166
+ const text = await (0, commands_js_1.handleList)(client);
167
+ for (const chunk of (0, format_js_1.splitMessage)(text))
168
+ await ctx.reply(chunk, { parse_mode: 'HTML' });
169
+ }
170
+ catch (err) {
171
+ await ctx.reply(`❌ ${err.message}`);
172
+ }
173
+ });
174
+ // ── /switch <id> ──
175
+ bot.command('switch', async (ctx) => {
176
+ if (!isAllowed(ctx.chat.id))
177
+ return;
178
+ const session = getSession(ctx.chat.id);
179
+ const newId = ctx.match?.trim();
180
+ if (!newId) {
181
+ await ctx.reply('Usage: /switch <thesis_id>');
182
+ return;
183
+ }
184
+ // Find matching thesis (prefix match)
185
+ try {
186
+ const { theses } = await client.listTheses();
187
+ const match = (theses || []).find((t) => t.id.startsWith(newId));
188
+ if (match) {
189
+ session.thesisId = match.id;
190
+ session.agentMessages = []; // reset conversation
191
+ // Restart poller
192
+ if (pollers.has(ctx.chat.id))
193
+ clearInterval(pollers.get(ctx.chat.id));
194
+ pollers.set(ctx.chat.id, (0, poller_js_1.startPoller)(bot, ctx.chat.id, client, session.thesisId));
195
+ await ctx.reply(`Switched to: <code>${match.id.slice(0, 8)}</code> — ${(match.rawThesis || '').slice(0, 60)}`, { parse_mode: 'HTML' });
196
+ }
197
+ else {
198
+ await ctx.reply(`No thesis found matching "${newId}". Use /list to see all.`);
199
+ }
200
+ }
201
+ catch (err) {
202
+ await ctx.reply(`❌ ${err.message}`);
203
+ }
204
+ });
205
+ // ── Free text → LLM agent ──
206
+ bot.on('message:text', async (ctx) => {
207
+ if (!isAllowed(ctx.chat.id))
208
+ return;
209
+ const session = getSession(ctx.chat.id);
210
+ const userMessage = ctx.message.text;
211
+ if (userMessage.startsWith('/'))
212
+ return; // skip unhandled commands
213
+ // Auto-detect thesis if not set
214
+ if (!session.thesisId) {
215
+ try {
216
+ const { theses } = await client.listTheses();
217
+ const active = (theses || []).filter((t) => t.status === 'active');
218
+ if (active.length > 0) {
219
+ session.thesisId = active[0].id;
220
+ }
221
+ else {
222
+ await ctx.reply('No active theses. Create one with `sf create "your thesis"` then /start');
223
+ return;
224
+ }
225
+ }
226
+ catch {
227
+ await ctx.reply('Could not connect. Use /start first.');
228
+ return;
229
+ }
230
+ }
231
+ try {
232
+ // Show "typing" indicator
233
+ await ctx.replyWithChatAction('typing');
234
+ // Lazy-load agent module (heavy imports)
235
+ const { runAgentMessage } = await import('./agent-bridge.js');
236
+ // Timeout after 30 seconds
237
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Response timeout (30s)')), 30_000));
238
+ const response = await Promise.race([
239
+ runAgentMessage(client, session, userMessage),
240
+ timeout,
241
+ ]);
242
+ if (!response || response.trim().length === 0) {
243
+ await ctx.reply('No response generated. Try rephrasing or use a slash command.');
244
+ }
245
+ else {
246
+ for (const chunk of (0, format_js_1.splitMessage)(response)) {
247
+ // Use plain text if HTML parsing might fail
248
+ try {
249
+ await ctx.reply(chunk, { parse_mode: 'HTML' });
250
+ }
251
+ catch {
252
+ await ctx.reply(chunk); // fallback to plain text
253
+ }
254
+ }
255
+ }
256
+ }
257
+ catch (err) {
258
+ console.error('[Telegram] Agent error:', err.message);
259
+ await ctx.reply(`❌ ${err.message}`);
260
+ }
261
+ });
262
+ // ── Callback queries (for inline keyboard confirmations) ──
263
+ bot.on('callback_query:data', async (ctx) => {
264
+ const data = ctx.callbackQuery.data;
265
+ if (data.startsWith('order_confirm:')) {
266
+ // TODO: execute pending order
267
+ await ctx.answerCallbackQuery({ text: 'Order execution coming soon' });
268
+ }
269
+ else if (data === 'order_cancel') {
270
+ await ctx.answerCallbackQuery({ text: 'Order cancelled' });
271
+ await ctx.editMessageText('❌ Order cancelled.');
272
+ }
273
+ });
274
+ // ── Start bot ──
275
+ console.log('🤖 SimpleFunctions Telegram bot starting...');
276
+ console.log(` SF API: ${config.apiKey ? '✓' : '✗'}`);
277
+ console.log(` Kalshi: ${process.env.KALSHI_API_KEY_ID ? '✓' : '✗'}`);
278
+ console.log(` OpenRouter: ${config.openrouterKey ? '✓' : '✗'}`);
279
+ if (allowedChat)
280
+ console.log(` Restricted to chat: ${allowedChat}`);
281
+ console.log(' Press Ctrl+C to stop.\n');
282
+ // Prevent crashes from killing the daemon
283
+ process.on('uncaughtException', (err) => {
284
+ console.error('[Telegram] Uncaught exception:', err.message);
285
+ });
286
+ process.on('unhandledRejection', (err) => {
287
+ console.error('[Telegram] Unhandled rejection:', err?.message || err);
288
+ });
289
+ // Cleanup on exit
290
+ process.on('SIGINT', () => {
291
+ for (const timer of pollers.values())
292
+ clearInterval(timer);
293
+ bot.stop();
294
+ process.exit(0);
295
+ });
296
+ await bot.start();
297
+ }