@spfunctions/cli 0.1.5 → 0.1.6

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.
@@ -1,16 +1,16 @@
1
1
  /**
2
- * sf agent — Interactive natural language terminal agent.
2
+ * sf agent — Interactive TUI agent powered by pi-tui + pi-agent-core.
3
3
  *
4
- * Uses pi-agent-core's Agent class for the tool-calling loop.
5
- * Uses pi-ai for unified LLM access via OpenRouter.
4
+ * Layout:
5
+ * [Header overlay] — thesis id, confidence, model
6
+ * [Spacer] — room for header
7
+ * [Chat container] — messages (user, assistant, tool, system)
8
+ * [Editor] — multi-line input with slash command autocomplete
9
+ * [Spacer] — room for footer
10
+ * [Footer overlay] — tokens, cost, tool count, /help hint
6
11
  *
7
- * The agent has 6 tools:
8
- * get_context — thesis snapshot (causal tree, edges, confidence)
9
- * inject_signal — inject news/note/external signal
10
- * trigger_evaluation — trigger deep eval (heavy model)
11
- * scan_markets — search Kalshi markets
12
- * list_theses — list all theses
13
- * get_positions — Kalshi portfolio positions
12
+ * Slash commands (bypass LLM):
13
+ * /help /tree /edges /pos /eval /model /clear /exit
14
14
  */
15
15
  export declare function agentCommand(thesisId?: string, opts?: {
16
16
  model?: string;
@@ -1,32 +1,266 @@
1
1
  "use strict";
2
2
  /**
3
- * sf agent — Interactive natural language terminal agent.
3
+ * sf agent — Interactive TUI agent powered by pi-tui + pi-agent-core.
4
4
  *
5
- * Uses pi-agent-core's Agent class for the tool-calling loop.
6
- * Uses pi-ai for unified LLM access via OpenRouter.
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
7
12
  *
8
- * The agent has 6 tools:
9
- * get_context — thesis snapshot (causal tree, edges, confidence)
10
- * inject_signal — inject news/note/external signal
11
- * trigger_evaluation — trigger deep eval (heavy model)
12
- * scan_markets — search Kalshi markets
13
- * list_theses — list all theses
14
- * get_positions — Kalshi portfolio positions
13
+ * Slash commands (bypass LLM):
14
+ * /help /tree /edges /pos /eval /model /clear /exit
15
15
  */
16
- var __importDefault = (this && this.__importDefault) || function (mod) {
17
- return (mod && mod.__esModule) ? mod : { "default": mod };
18
- };
19
16
  Object.defineProperty(exports, "__esModule", { value: true });
20
17
  exports.agentCommand = agentCommand;
21
- const readline_1 = __importDefault(require("readline"));
22
18
  const client_js_1 = require("../client.js");
23
19
  const kalshi_js_1 = require("../kalshi.js");
20
+ // ─── ANSI 24-bit color helpers (no chalk dependency) ─────────────────────────
21
+ const rgb = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
22
+ const bgRgb = (r, g, b) => (s) => `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`;
23
+ const bold = (s) => `\x1b[1m${s}\x1b[22m`;
24
+ const dim = (s) => `\x1b[2m${s}\x1b[22m`;
25
+ const italic = (s) => `\x1b[3m${s}\x1b[23m`;
26
+ const underline = (s) => `\x1b[4m${s}\x1b[24m`;
27
+ const strikethrough = (s) => `\x1b[9m${s}\x1b[29m`;
28
+ const C = {
29
+ emerald: rgb(16, 185, 129), // #10b981
30
+ zinc200: rgb(228, 228, 231), // #e4e4e7
31
+ zinc400: rgb(161, 161, 170), // #a1a1aa
32
+ zinc600: rgb(82, 82, 91), // #52525b
33
+ zinc800: rgb(39, 39, 42), // #27272a
34
+ red: rgb(239, 68, 68), // #ef4444
35
+ amber: rgb(245, 158, 11), // #f59e0b
36
+ white: rgb(255, 255, 255),
37
+ bgZinc900: bgRgb(24, 24, 27), // #18181b
38
+ bgZinc800: bgRgb(39, 39, 42), // #27272a
39
+ };
40
+ // ─── Custom components ───────────────────────────────────────────────────────
41
+ /** Mutable single-line component (TruncatedText is immutable) */
42
+ function createMutableLine(piTui) {
43
+ const { truncateToWidth, visibleWidth } = piTui;
44
+ return class MutableLine {
45
+ text;
46
+ cachedWidth;
47
+ cachedLines;
48
+ constructor(text) {
49
+ this.text = text;
50
+ }
51
+ setText(text) {
52
+ this.text = text;
53
+ this.cachedWidth = undefined;
54
+ this.cachedLines = undefined;
55
+ }
56
+ invalidate() {
57
+ this.cachedWidth = undefined;
58
+ this.cachedLines = undefined;
59
+ }
60
+ render(width) {
61
+ if (this.cachedLines && this.cachedWidth === width)
62
+ return this.cachedLines;
63
+ this.cachedWidth = width;
64
+ this.cachedLines = [truncateToWidth(this.text, width)];
65
+ return this.cachedLines;
66
+ }
67
+ };
68
+ }
69
+ /** Header bar: [SF Agent — {id}] [{confidence}%] [{model}] */
70
+ function createHeaderBar(piTui) {
71
+ const { truncateToWidth, visibleWidth } = piTui;
72
+ return class HeaderBar {
73
+ left;
74
+ center;
75
+ right;
76
+ cachedWidth;
77
+ cachedLines;
78
+ constructor(left, center, right) {
79
+ this.left = left;
80
+ this.center = center;
81
+ this.right = right;
82
+ }
83
+ update(left, center, right) {
84
+ if (left !== undefined)
85
+ this.left = left;
86
+ if (center !== undefined)
87
+ this.center = center;
88
+ if (right !== undefined)
89
+ this.right = right;
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
+ const l = this.left;
102
+ const c = this.center;
103
+ const r = this.right;
104
+ const lw = visibleWidth(l);
105
+ const cw = visibleWidth(c);
106
+ const rw = visibleWidth(r);
107
+ const totalContent = lw + cw + rw;
108
+ const totalPad = Math.max(0, width - totalContent);
109
+ const leftPad = Math.max(1, Math.floor(totalPad / 2));
110
+ const rightPad = Math.max(1, totalPad - leftPad);
111
+ let line = l + ' '.repeat(leftPad) + c + ' '.repeat(rightPad) + r;
112
+ line = C.bgZinc900(truncateToWidth(line, width, ''));
113
+ // Pad to full width for background
114
+ const lineVw = visibleWidth(line);
115
+ if (lineVw < width) {
116
+ line = line + C.bgZinc900(' '.repeat(width - lineVw));
117
+ }
118
+ this.cachedLines = [line];
119
+ return this.cachedLines;
120
+ }
121
+ };
122
+ }
123
+ /** Footer bar: [tokens: N | cost: $N | tools: N] [/help] */
124
+ function createFooterBar(piTui) {
125
+ const { truncateToWidth, visibleWidth } = piTui;
126
+ return class FooterBar {
127
+ tokens = 0;
128
+ cost = 0;
129
+ toolCount = 0;
130
+ cachedWidth;
131
+ cachedLines;
132
+ invalidate() {
133
+ this.cachedWidth = undefined;
134
+ this.cachedLines = undefined;
135
+ }
136
+ update() {
137
+ this.cachedWidth = undefined;
138
+ this.cachedLines = undefined;
139
+ }
140
+ render(width) {
141
+ if (this.cachedLines && this.cachedWidth === width)
142
+ return this.cachedLines;
143
+ this.cachedWidth = width;
144
+ const tokStr = this.tokens >= 1000
145
+ ? `${(this.tokens / 1000).toFixed(1)}k`
146
+ : `${this.tokens}`;
147
+ const leftText = C.zinc600(`tokens: ${tokStr} cost: $${this.cost.toFixed(3)} tools: ${this.toolCount}`);
148
+ const rightText = C.zinc600('/help');
149
+ const lw = visibleWidth(leftText);
150
+ const rw = visibleWidth(rightText);
151
+ const gap = Math.max(1, width - lw - rw);
152
+ let line = leftText + ' '.repeat(gap) + rightText;
153
+ line = C.bgZinc900(truncateToWidth(line, width, ''));
154
+ const lineVw = visibleWidth(line);
155
+ if (lineVw < width) {
156
+ line = line + C.bgZinc900(' '.repeat(width - lineVw));
157
+ }
158
+ this.cachedLines = [line];
159
+ return this.cachedLines;
160
+ }
161
+ };
162
+ }
163
+ // ─── Formatted renderers ─────────────────────────────────────────────────────
164
+ function renderCausalTree(context, piTui) {
165
+ const tree = context.causalTree;
166
+ if (!tree?.nodes?.length)
167
+ return C.zinc600(' No causal tree data');
168
+ const lines = [];
169
+ for (const node of tree.nodes) {
170
+ const id = node.id || '';
171
+ const label = node.label || node.description || '';
172
+ const prob = typeof node.probability === 'number'
173
+ ? Math.round(node.probability * 100)
174
+ : (typeof node.impliedProbability === 'number' ? Math.round(node.impliedProbability * 100) : null);
175
+ const depth = (id.match(/\./g) || []).length;
176
+ const indent = ' '.repeat(depth + 1);
177
+ if (prob !== null) {
178
+ // Progress bar: 10 chars
179
+ const filled = Math.round(prob / 10);
180
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
181
+ const probColor = prob >= 70 ? C.emerald : prob >= 40 ? C.amber : C.red;
182
+ // Dots to pad between label and percentage
183
+ const labelPart = `${indent}${C.zinc600(id)} ${C.zinc400(label)} `;
184
+ const probPart = ` ${probColor(`${prob}%`)} ${probColor(bar)}`;
185
+ lines.push(labelPart + probPart);
186
+ }
187
+ else {
188
+ lines.push(`${indent}${C.zinc600(id)} ${C.zinc400(label)}`);
189
+ }
190
+ }
191
+ return lines.join('\n');
192
+ }
193
+ function renderEdges(context, piTui) {
194
+ const edges = context.edges;
195
+ if (!edges?.length)
196
+ return C.zinc600(' No edge data');
197
+ const positions = context._positions || [];
198
+ const lines = [];
199
+ for (const e of edges) {
200
+ const name = (e.marketTitle || e.ticker || '').slice(0, 18).padEnd(18);
201
+ const market = typeof e.marketPrice === 'number' ? `${e.marketPrice}\u00A2` : '?';
202
+ const thesis = typeof e.thesisImpliedPrice === 'number' ? `${e.thesisImpliedPrice}\u00A2` : '?';
203
+ const edge = typeof e.edgeSize === 'number' ? (e.edgeSize > 0 ? `+${e.edgeSize}` : `${e.edgeSize}`) : '?';
204
+ const spread = typeof e.spread === 'number' ? `${e.spread}\u00A2` : '?';
205
+ const liq = e.liquidityScore || 'low';
206
+ const liqBars = liq === 'high' ? '\u25A0\u25A0\u25A0' : liq === 'medium' ? '\u25A0\u25A0 ' : '\u25A0 ';
207
+ const liqColor = liq === 'high' ? C.emerald : liq === 'medium' ? C.amber : C.red;
208
+ // Check if we have a position on this edge
209
+ const pos = positions.find((p) => p.ticker === e.ticker);
210
+ let posStr = C.zinc600('\u2014');
211
+ if (pos) {
212
+ const side = pos.side?.toUpperCase() || 'YES';
213
+ const pnl = typeof pos.unrealized_pnl === 'number'
214
+ ? (pos.unrealized_pnl >= 0 ? C.emerald(`+$${(pos.unrealized_pnl / 100).toFixed(0)}`) : C.red(`-$${(Math.abs(pos.unrealized_pnl) / 100).toFixed(0)}`))
215
+ : '';
216
+ posStr = C.emerald(`${side} (${pos.quantity}@${pos.average_price_paid}\u00A2 ${pnl})`);
217
+ }
218
+ lines.push(` ${C.zinc200(name)} ${C.zinc400(market)} \u2192 ${C.zinc400(thesis)} edge ${edge.includes('+') ? C.emerald(edge) : C.red(edge)} spread ${C.zinc600(spread)} ${liqColor(liqBars)} ${liqColor(liq.padEnd(4))} ${posStr}`);
219
+ }
220
+ return lines.join('\n');
221
+ }
222
+ function renderPositions(positions) {
223
+ if (!positions?.length)
224
+ return C.zinc600(' No positions');
225
+ const lines = [];
226
+ let totalPnl = 0;
227
+ for (const p of positions) {
228
+ const ticker = (p.ticker || '').slice(0, 18).padEnd(18);
229
+ const side = (p.side || 'yes').toUpperCase().padEnd(3);
230
+ const qty = String(p.quantity || 0);
231
+ const avg = `${p.average_price_paid || 0}\u00A2`;
232
+ const now = typeof p.current_value === 'number' && p.current_value > 0
233
+ ? `${p.current_value}\u00A2`
234
+ : '?\u00A2';
235
+ const pnlCents = p.unrealized_pnl || 0;
236
+ totalPnl += pnlCents;
237
+ const pnlDollars = (pnlCents / 100).toFixed(2);
238
+ const pnlStr = pnlCents >= 0
239
+ ? C.emerald(`+$${pnlDollars}`)
240
+ : C.red(`-$${Math.abs(parseFloat(pnlDollars)).toFixed(2)}`);
241
+ const arrow = pnlCents >= 0 ? C.emerald('\u25B2') : C.red('\u25BC');
242
+ lines.push(` ${C.zinc200(ticker)} ${C.zinc400(side)} ${C.zinc400(qty)} @ ${C.zinc400(avg)} now ${C.zinc200(now)} ${pnlStr} ${arrow}`);
243
+ }
244
+ const totalDollars = (totalPnl / 100).toFixed(2);
245
+ lines.push(C.zinc600(' ' + '\u2500'.repeat(40)));
246
+ lines.push(totalPnl >= 0
247
+ ? ` Total P&L: ${C.emerald(bold(`+$${totalDollars}`))}`
248
+ : ` Total P&L: ${C.red(bold(`-$${Math.abs(parseFloat(totalDollars)).toFixed(2)}`))}`);
249
+ return lines.join('\n');
250
+ }
251
+ // ─── Main command ────────────────────────────────────────────────────────────
24
252
  async function agentCommand(thesisId, opts) {
25
- // ── Dynamic imports for ESM-only packages ──────────────────────────────────
253
+ // ── Dynamic imports (all ESM-only packages) ────────────────────────────────
254
+ const piTui = await import('@mariozechner/pi-tui');
26
255
  const piAi = await import('@mariozechner/pi-ai');
27
256
  const piAgent = await import('@mariozechner/pi-agent-core');
257
+ const { TUI, ProcessTerminal, Container, Text, Markdown, Editor, Loader, Spacer, CombinedAutocompleteProvider, truncateToWidth, visibleWidth, } = piTui;
28
258
  const { getModel, streamSimple, Type } = piAi;
29
259
  const { Agent } = piAgent;
260
+ // ── Component class factories (need piTui ref) ─────────────────────────────
261
+ const MutableLine = createMutableLine(piTui);
262
+ const HeaderBar = createHeaderBar(piTui);
263
+ const FooterBar = createFooterBar(piTui);
30
264
  // ── Validate API keys ──────────────────────────────────────────────────────
31
265
  const openrouterKey = opts?.modelKey || process.env.OPENROUTER_API_KEY;
32
266
  if (!openrouterKey) {
@@ -47,8 +281,124 @@ async function agentCommand(thesisId, opts) {
47
281
  resolvedThesisId = active.id;
48
282
  }
49
283
  // ── Fetch initial context ──────────────────────────────────────────────────
50
- const context = await sfClient.getContext(resolvedThesisId);
51
- // ── Define tools ───────────────────────────────────────────────────────────
284
+ let latestContext = await sfClient.getContext(resolvedThesisId);
285
+ // ── Model setup ────────────────────────────────────────────────────────────
286
+ const rawModelName = opts?.model || 'anthropic/claude-sonnet-4-20250514';
287
+ let currentModelName = rawModelName.replace(/^openrouter\//, '');
288
+ function resolveModel(name) {
289
+ try {
290
+ return getModel('openrouter', name);
291
+ }
292
+ catch {
293
+ return {
294
+ modelId: name,
295
+ provider: 'openrouter',
296
+ api: 'openai-completions',
297
+ baseUrl: 'https://openrouter.ai/api/v1',
298
+ id: name,
299
+ name: name,
300
+ inputPrice: 0,
301
+ outputPrice: 0,
302
+ contextWindow: 200000,
303
+ supportsImages: true,
304
+ supportsTools: true,
305
+ };
306
+ }
307
+ }
308
+ let model = resolveModel(currentModelName);
309
+ // ── Tracking state ─────────────────────────────────────────────────────────
310
+ let totalTokens = 0;
311
+ let totalCost = 0;
312
+ let totalToolCalls = 0;
313
+ let isProcessing = false;
314
+ // Cache for positions (fetched by /pos or get_positions tool)
315
+ let cachedPositions = null;
316
+ // ── Setup TUI ──────────────────────────────────────────────────────────────
317
+ const terminal = new ProcessTerminal();
318
+ const tui = new TUI(terminal);
319
+ // Markdown theme for assistant messages
320
+ const mdTheme = {
321
+ heading: (s) => C.zinc200(bold(s)),
322
+ link: (s) => C.emerald(s),
323
+ linkUrl: (s) => C.zinc600(s),
324
+ code: (s) => C.zinc200(s),
325
+ codeBlock: (s) => C.zinc400(s),
326
+ codeBlockBorder: (s) => C.zinc600(s),
327
+ quote: (s) => C.zinc400(s),
328
+ quoteBorder: (s) => C.zinc600(s),
329
+ hr: (s) => C.zinc600(s),
330
+ listBullet: (s) => C.emerald(s),
331
+ bold: (s) => bold(s),
332
+ italic: (s) => italic(s),
333
+ strikethrough: (s) => strikethrough(s),
334
+ underline: (s) => underline(s),
335
+ };
336
+ const mdDefaultStyle = {
337
+ color: (s) => C.zinc400(s),
338
+ };
339
+ // Editor theme
340
+ const editorTheme = {
341
+ borderColor: (s) => C.zinc800(s),
342
+ selectList: {
343
+ selectedPrefix: (s) => C.emerald(s),
344
+ selectedText: (s) => C.zinc200(s),
345
+ description: (s) => C.zinc600(s),
346
+ scrollInfo: (s) => C.zinc600(s),
347
+ noMatch: (s) => C.zinc600(s),
348
+ },
349
+ };
350
+ // ── Build components ───────────────────────────────────────────────────────
351
+ const shortId = (resolvedThesisId || '').slice(0, 8);
352
+ const confidencePct = typeof latestContext.confidence === 'number'
353
+ ? Math.round(latestContext.confidence * 100)
354
+ : (typeof latestContext.confidence === 'string' ? parseInt(latestContext.confidence) : 0);
355
+ const headerBar = new HeaderBar(C.emerald(bold('SF Agent')) + C.zinc600(` \u2014 ${shortId}`), confidencePct > 0 ? C.zinc200(`${confidencePct}%`) : '', C.zinc600(currentModelName.split('/').pop() || currentModelName));
356
+ const footerBar = new FooterBar();
357
+ const topSpacer = new Spacer(1);
358
+ const bottomSpacer = new Spacer(1);
359
+ const chatContainer = new Container();
360
+ const editor = new Editor(tui, editorTheme, { paddingX: 1 });
361
+ // Slash command autocomplete
362
+ const autocompleteProvider = new CombinedAutocompleteProvider([
363
+ { name: 'help', description: 'Show available commands' },
364
+ { name: 'tree', description: 'Display causal tree' },
365
+ { name: 'edges', description: 'Display edge/spread table' },
366
+ { name: 'pos', description: 'Display Kalshi positions' },
367
+ { name: 'eval', description: 'Trigger deep evaluation' },
368
+ { name: 'model', description: 'Switch model (e.g. /model anthropic/claude-sonnet-4)' },
369
+ { name: 'clear', description: 'Clear chat' },
370
+ { name: 'exit', description: 'Exit agent' },
371
+ ], process.cwd());
372
+ editor.setAutocompleteProvider(autocompleteProvider);
373
+ // Assemble TUI tree
374
+ tui.addChild(topSpacer);
375
+ tui.addChild(chatContainer);
376
+ tui.addChild(editor);
377
+ tui.addChild(bottomSpacer);
378
+ // Focus on editor
379
+ tui.setFocus(editor);
380
+ // ── Overlays (pinned header + footer) ──────────────────────────────────────
381
+ const headerOverlay = tui.showOverlay(headerBar, {
382
+ row: 0,
383
+ col: 0,
384
+ width: '100%',
385
+ nonCapturing: true,
386
+ });
387
+ const footerOverlay = tui.showOverlay(footerBar, {
388
+ anchor: 'bottom-left',
389
+ width: '100%',
390
+ nonCapturing: true,
391
+ });
392
+ // ── Helper: add system text to chat ────────────────────────────────────────
393
+ function addSystemText(content) {
394
+ const text = new Text(content, 1, 0);
395
+ chatContainer.addChild(text);
396
+ tui.requestRender();
397
+ }
398
+ function addSpacer() {
399
+ chatContainer.addChild(new Spacer(1));
400
+ }
401
+ // ── Define agent tools (same as before) ────────────────────────────────────
52
402
  const thesisIdParam = Type.Object({
53
403
  thesisId: Type.String({ description: 'Thesis ID (short or full UUID)' }),
54
404
  });
@@ -71,6 +421,13 @@ async function agentCommand(thesisId, opts) {
71
421
  parameters: thesisIdParam,
72
422
  execute: async (_toolCallId, params) => {
73
423
  const ctx = await sfClient.getContext(params.thesisId);
424
+ latestContext = ctx;
425
+ // Update header with new confidence
426
+ const conf = typeof ctx.confidence === 'number'
427
+ ? Math.round(ctx.confidence * 100)
428
+ : 0;
429
+ headerBar.update(undefined, conf > 0 ? C.zinc200(`${conf}%`) : '', undefined);
430
+ tui.requestRender();
74
431
  return {
75
432
  content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }],
76
433
  details: {},
@@ -150,22 +507,16 @@ async function agentCommand(thesisId, opts) {
150
507
  {
151
508
  name: 'get_positions',
152
509
  label: 'Get Positions',
153
- description: 'Get Kalshi exchange positions with live prices and PnL (requires local Kalshi key config)',
510
+ description: 'Get Kalshi exchange positions with live prices and PnL',
154
511
  parameters: emptyParams,
155
512
  execute: async () => {
156
513
  const positions = await (0, kalshi_js_1.getPositions)();
157
514
  if (!positions) {
158
515
  return {
159
- content: [
160
- {
161
- type: 'text',
162
- text: 'Kalshi not configured. Set KALSHI_API_KEY_ID and KALSHI_PRIVATE_KEY_PATH environment variables.',
163
- },
164
- ],
516
+ content: [{ type: 'text', text: 'Kalshi not configured. Set KALSHI_API_KEY_ID and KALSHI_PRIVATE_KEY_PATH.' }],
165
517
  details: {},
166
518
  };
167
519
  }
168
- // Enrich with live prices (same logic as sf positions command)
169
520
  for (const pos of positions) {
170
521
  const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
171
522
  if (livePrice !== null) {
@@ -173,6 +524,7 @@ async function agentCommand(thesisId, opts) {
173
524
  pos.unrealized_pnl = Math.round((livePrice - pos.average_price_paid) * pos.quantity);
174
525
  }
175
526
  }
527
+ cachedPositions = positions;
176
528
  return {
177
529
  content: [{ type: 'text', text: JSON.stringify(positions, null, 2) }],
178
530
  details: {},
@@ -181,47 +533,16 @@ async function agentCommand(thesisId, opts) {
181
533
  },
182
534
  ];
183
535
  // ── System prompt ──────────────────────────────────────────────────────────
184
- const confidencePct = typeof context.confidence === 'number'
185
- ? Math.round(context.confidence * 100)
186
- : context.confidence;
187
536
  const systemPrompt = `You are a SimpleFunctions prediction market trading assistant.
188
537
 
189
- Current thesis: ${context.thesis || context.rawThesis || 'N/A'}
538
+ Current thesis: ${latestContext.thesis || latestContext.rawThesis || 'N/A'}
190
539
  Confidence: ${confidencePct}%
191
- Status: ${context.status}
192
- Thesis ID: ${context.thesisId || resolvedThesisId}
540
+ Status: ${latestContext.status}
541
+ Thesis ID: ${latestContext.thesisId || resolvedThesisId}
193
542
 
194
543
  You have six tools available. Use them when you need real-time data. Answer directly when you don't.
195
544
  Be concise. Use Chinese if the user writes in Chinese, English if they write in English.
196
545
  Do NOT make up data. Always call tools to get current state.`;
197
- // ── Configure model via OpenRouter ─────────────────────────────────────────
198
- const rawModelName = opts?.model || 'anthropic/claude-sonnet-4-20250514';
199
- // Strip 'openrouter/' prefix if user passed it (provider is already openrouter)
200
- const modelName = rawModelName.replace(/^openrouter\//, '');
201
- // Try the registry first. If the model isn't registered, construct manually.
202
- // OpenRouter accepts any model ID — the registry just may not have it yet.
203
- let model;
204
- try {
205
- model = getModel('openrouter', modelName);
206
- }
207
- catch {
208
- // Manual fallback: construct a Model object compatible with pi-ai's openai-completions API
209
- // (OpenRouter uses OpenAI-compatible API)
210
- model = {
211
- modelId: modelName,
212
- provider: 'openrouter',
213
- api: 'openai-completions',
214
- baseUrl: 'https://openrouter.ai/api/v1',
215
- id: modelName,
216
- name: modelName,
217
- inputPrice: 0,
218
- outputPrice: 0,
219
- contextWindow: 200000,
220
- supportsImages: true,
221
- supportsTools: true,
222
- };
223
- console.log(`\x1b[33mModel '${modelName}' not in registry, using direct OpenRouter API\x1b[0m`);
224
- }
225
546
  // ── Create Agent ───────────────────────────────────────────────────────────
226
547
  const agent = new Agent({
227
548
  initialState: {
@@ -237,54 +558,272 @@ Do NOT make up data. Always call tools to get current state.`;
237
558
  return undefined;
238
559
  },
239
560
  });
240
- // ── Subscribe to streaming events ──────────────────────────────────────────
561
+ // ── Subscribe to agent events → update TUI ────────────────────────────────
562
+ let currentAssistantMd = null;
563
+ let currentAssistantText = '';
564
+ let currentLoader = null;
565
+ const toolStartTimes = new Map();
566
+ const toolLines = new Map();
241
567
  agent.subscribe((event) => {
568
+ if (event.type === 'message_start') {
569
+ // Show loader while waiting for first text
570
+ currentAssistantText = '';
571
+ currentAssistantMd = null;
572
+ currentLoader = new Loader(tui, (s) => C.emerald(s), (s) => C.zinc600(s), 'thinking...');
573
+ currentLoader.start();
574
+ chatContainer.addChild(currentLoader);
575
+ tui.requestRender();
576
+ }
242
577
  if (event.type === 'message_update') {
243
578
  const e = event.assistantMessageEvent;
244
579
  if (e.type === 'text_delta') {
245
- process.stdout.write(e.delta);
580
+ // Remove loader on first text delta
581
+ if (currentLoader) {
582
+ currentLoader.stop();
583
+ chatContainer.removeChild(currentLoader);
584
+ currentLoader = null;
585
+ // Create markdown component for assistant response
586
+ currentAssistantMd = new Markdown('', 1, 0, mdTheme, mdDefaultStyle);
587
+ chatContainer.addChild(currentAssistantMd);
588
+ }
589
+ currentAssistantText += e.delta;
590
+ if (currentAssistantMd) {
591
+ currentAssistantMd.setText(currentAssistantText);
592
+ }
593
+ tui.requestRender();
594
+ }
595
+ }
596
+ if (event.type === 'message_complete') {
597
+ // Clean up loader if still present (no text was generated)
598
+ if (currentLoader) {
599
+ currentLoader.stop();
600
+ chatContainer.removeChild(currentLoader);
601
+ currentLoader = null;
602
+ }
603
+ // Add spacer after message
604
+ addSpacer();
605
+ currentAssistantMd = null;
606
+ currentAssistantText = '';
607
+ isProcessing = false;
608
+ tui.requestRender();
609
+ // Update token/cost tracking from event if available
610
+ if (event.usage) {
611
+ totalTokens += (event.usage.inputTokens || 0) + (event.usage.outputTokens || 0);
612
+ totalCost += event.usage.totalCost || 0;
613
+ footerBar.tokens = totalTokens;
614
+ footerBar.cost = totalCost;
615
+ footerBar.update();
246
616
  }
247
617
  }
248
618
  if (event.type === 'tool_execution_start') {
249
- process.stdout.write(`\n\x1b[33m⚡ ${event.toolName}\x1b[0m `);
619
+ const toolLine = new MutableLine(C.zinc600(` \u26A1 ${event.toolName}...`));
620
+ toolStartTimes.set(event.toolCallId || event.toolName, Date.now());
621
+ toolLines.set(event.toolCallId || event.toolName, toolLine);
622
+ chatContainer.addChild(toolLine);
623
+ totalToolCalls++;
624
+ footerBar.toolCount = totalToolCalls;
625
+ footerBar.update();
626
+ tui.requestRender();
250
627
  }
251
628
  if (event.type === 'tool_execution_end') {
252
- if (event.isError) {
253
- process.stdout.write('\x1b[31m✗\x1b[0m\n');
254
- }
255
- else {
256
- process.stdout.write('\x1b[32m✓\x1b[0m\n');
629
+ const key = event.toolCallId || event.toolName;
630
+ const startTime = toolStartTimes.get(key);
631
+ const elapsed = startTime ? ((Date.now() - startTime) / 1000).toFixed(1) : '?';
632
+ const line = toolLines.get(key);
633
+ if (line) {
634
+ if (event.isError) {
635
+ line.setText(C.red(` \u2717 ${event.toolName} (${elapsed}s) error`));
636
+ }
637
+ else {
638
+ line.setText(C.zinc600(` \u26A1 ${event.toolName}`) + C.emerald(` \u2713`) + C.zinc600(` (${elapsed}s)`));
639
+ }
257
640
  }
641
+ toolStartTimes.delete(key);
642
+ toolLines.delete(key);
643
+ tui.requestRender();
258
644
  }
259
645
  });
260
- // ── Terminal REPL ──────────────────────────────────────────────────────────
261
- const shortId = (resolvedThesisId || '').slice(0, 8);
262
- console.log(`\n\x1b[32mSimpleFunctions Agent\x1b[0m ${shortId}`);
263
- console.log(`Model: ${modelName} | Type "exit" to quit\n`);
264
- const rl = readline_1.default.createInterface({
265
- input: process.stdin,
266
- output: process.stdout,
267
- });
268
- const prompt = () => {
269
- rl.question('\x1b[36m> \x1b[0m', async (input) => {
270
- const trimmed = input.trim();
271
- if (!trimmed) {
272
- prompt();
273
- return;
646
+ // ── Slash command handlers ─────────────────────────────────────────────────
647
+ async function handleSlashCommand(cmd) {
648
+ const parts = cmd.trim().split(/\s+/);
649
+ const command = parts[0].toLowerCase();
650
+ switch (command) {
651
+ case '/help': {
652
+ addSpacer();
653
+ addSystemText(C.zinc200(bold('Commands')) + '\n' +
654
+ C.emerald('/help ') + C.zinc400(' Show this help') + '\n' +
655
+ C.emerald('/tree ') + C.zinc400(' Display causal tree') + '\n' +
656
+ C.emerald('/edges ') + C.zinc400(' Display edge/spread table') + '\n' +
657
+ C.emerald('/pos ') + C.zinc400(' Display Kalshi positions') + '\n' +
658
+ C.emerald('/eval ') + C.zinc400(' Trigger deep evaluation') + '\n' +
659
+ C.emerald('/model ') + C.zinc400(' Switch model (e.g. /model anthropic/claude-sonnet-4)') + '\n' +
660
+ C.emerald('/clear ') + C.zinc400(' Clear chat') + '\n' +
661
+ C.emerald('/exit ') + C.zinc400(' Exit agent'));
662
+ addSpacer();
663
+ return true;
664
+ }
665
+ case '/tree': {
666
+ addSpacer();
667
+ // Refresh context first
668
+ try {
669
+ latestContext = await sfClient.getContext(resolvedThesisId);
670
+ addSystemText(C.zinc200(bold('Causal Tree')) + '\n' + renderCausalTree(latestContext, piTui));
671
+ }
672
+ catch (err) {
673
+ addSystemText(C.red(`Error: ${err.message}`));
674
+ }
675
+ addSpacer();
676
+ return true;
677
+ }
678
+ case '/edges': {
679
+ addSpacer();
680
+ try {
681
+ latestContext = await sfClient.getContext(resolvedThesisId);
682
+ // Attach cached positions for display
683
+ if (cachedPositions) {
684
+ latestContext._positions = cachedPositions;
685
+ }
686
+ addSystemText(C.zinc200(bold('Edges')) + '\n' + renderEdges(latestContext, piTui));
687
+ }
688
+ catch (err) {
689
+ addSystemText(C.red(`Error: ${err.message}`));
690
+ }
691
+ addSpacer();
692
+ return true;
274
693
  }
275
- if (trimmed === 'exit' || trimmed === 'quit') {
276
- rl.close();
277
- process.exit(0);
694
+ case '/pos': {
695
+ addSpacer();
696
+ try {
697
+ const positions = await (0, kalshi_js_1.getPositions)();
698
+ if (!positions) {
699
+ addSystemText(C.zinc600('Kalshi not configured'));
700
+ return true;
701
+ }
702
+ for (const pos of positions) {
703
+ const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
704
+ if (livePrice !== null) {
705
+ pos.current_value = livePrice;
706
+ pos.unrealized_pnl = Math.round((livePrice - pos.average_price_paid) * pos.quantity);
707
+ }
708
+ }
709
+ cachedPositions = positions;
710
+ addSystemText(C.zinc200(bold('Positions')) + '\n' + renderPositions(positions));
711
+ }
712
+ catch (err) {
713
+ addSystemText(C.red(`Error: ${err.message}`));
714
+ }
715
+ addSpacer();
716
+ return true;
717
+ }
718
+ case '/eval': {
719
+ addSpacer();
720
+ addSystemText(C.zinc600('Triggering evaluation...'));
721
+ tui.requestRender();
722
+ try {
723
+ const result = await sfClient.evaluate(resolvedThesisId);
724
+ addSystemText(C.emerald('Evaluation complete') + '\n' + C.zinc400(JSON.stringify(result, null, 2)));
725
+ }
726
+ catch (err) {
727
+ addSystemText(C.red(`Error: ${err.message}`));
728
+ }
729
+ addSpacer();
730
+ return true;
731
+ }
732
+ case '/model': {
733
+ const newModel = parts.slice(1).join(' ').trim();
734
+ if (!newModel) {
735
+ addSystemText(C.zinc400(`Current model: ${currentModelName}`));
736
+ return true;
737
+ }
738
+ addSpacer();
739
+ currentModelName = newModel.replace(/^openrouter\//, '');
740
+ model = resolveModel(currentModelName);
741
+ // Update agent model
742
+ agent.setModel(model);
743
+ headerBar.update(undefined, undefined, C.zinc600(currentModelName.split('/').pop() || currentModelName));
744
+ addSystemText(C.emerald(`Model switched to ${currentModelName}`));
745
+ addSpacer();
746
+ tui.requestRender();
747
+ return true;
278
748
  }
279
- try {
280
- await agent.prompt(trimmed);
281
- console.log('\n');
749
+ case '/clear': {
750
+ chatContainer.clear();
751
+ tui.requestRender();
752
+ return true;
282
753
  }
283
- catch (err) {
284
- console.error(`\n\x1b[31mError: ${err.message}\x1b[0m\n`);
754
+ case '/exit':
755
+ case '/quit': {
756
+ cleanup();
757
+ return true;
285
758
  }
286
- prompt();
287
- });
759
+ default:
760
+ return false;
761
+ }
762
+ }
763
+ // ── Editor submit handler ──────────────────────────────────────────────────
764
+ editor.onSubmit = async (input) => {
765
+ const trimmed = input.trim();
766
+ if (!trimmed)
767
+ return;
768
+ if (isProcessing)
769
+ return;
770
+ // Add to editor history
771
+ editor.addToHistory(trimmed);
772
+ // Check for slash commands
773
+ if (trimmed.startsWith('/')) {
774
+ const handled = await handleSlashCommand(trimmed);
775
+ if (handled)
776
+ return;
777
+ }
778
+ // Regular message → send to agent
779
+ isProcessing = true;
780
+ // Add user message to chat
781
+ const userMsg = new Text(C.emerald(bold('>')) + ' ' + C.white(trimmed), 1, 0);
782
+ chatContainer.addChild(userMsg);
783
+ addSpacer();
784
+ tui.requestRender();
785
+ try {
786
+ await agent.prompt(trimmed);
787
+ }
788
+ catch (err) {
789
+ // Remove loader if present
790
+ if (currentLoader) {
791
+ currentLoader.stop();
792
+ chatContainer.removeChild(currentLoader);
793
+ currentLoader = null;
794
+ }
795
+ addSystemText(C.red(`Error: ${err.message}`));
796
+ addSpacer();
797
+ isProcessing = false;
798
+ }
288
799
  };
289
- prompt();
800
+ // ── Ctrl+C handler ─────────────────────────────────────────────────────────
801
+ function cleanup() {
802
+ if (currentLoader)
803
+ currentLoader.stop();
804
+ tui.stop();
805
+ process.exit(0);
806
+ }
807
+ // Listen for Ctrl+C at the TUI level
808
+ tui.addInputListener((data) => {
809
+ // Ctrl+C = \x03
810
+ if (data === '\x03') {
811
+ cleanup();
812
+ return { consume: true };
813
+ }
814
+ return undefined;
815
+ });
816
+ // Also handle SIGINT
817
+ process.on('SIGINT', cleanup);
818
+ process.on('SIGTERM', cleanup);
819
+ // ── Show initial welcome ───────────────────────────────────────────────────
820
+ const thesisText = latestContext.thesis || latestContext.rawThesis || 'N/A';
821
+ const truncatedThesis = thesisText.length > 120 ? thesisText.slice(0, 120) + '...' : thesisText;
822
+ addSystemText(C.zinc600('\u2500'.repeat(50)) + '\n' +
823
+ C.zinc200(bold(truncatedThesis)) + '\n' +
824
+ C.zinc600(`${latestContext.status || 'active'} ${confidencePct > 0 ? confidencePct + '%' : ''} ${(latestContext.edges || []).length} edges`) + '\n' +
825
+ C.zinc600('\u2500'.repeat(50)));
826
+ addSpacer();
827
+ // ── Start TUI ──────────────────────────────────────────────────────────────
828
+ tui.start();
290
829
  }
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "@spfunctions/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "CLI for SimpleFunctions prediction market thesis agent",
5
5
  "bin": {
6
6
  "sf": "./dist/index.js"
7
7
  },
8
8
  "scripts": {
9
- "build": "tsc",
9
+ "build": "node ./node_modules/typescript/bin/tsc",
10
10
  "dev": "tsx src/index.ts",
11
11
  "prepublishOnly": "npm run build"
12
12
  },
13
13
  "dependencies": {
14
14
  "@mariozechner/pi-agent-core": "^0.57.1",
15
15
  "@mariozechner/pi-ai": "^0.57.1",
16
+ "@mariozechner/pi-tui": "^0.57.1",
16
17
  "commander": "^12.0.0",
17
18
  "kalshi-typescript": "^3.9.0"
18
19
  },