@spfunctions/cli 0.1.5 → 0.1.7

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,269 @@
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
+ // Context API field names: market, marketId, thesisPrice, edge, orderbook.spread, orderbook.liquidityScore
201
+ const name = (e.market || e.marketId || '').slice(0, 18).padEnd(18);
202
+ const marketStr = typeof e.marketPrice === 'number' ? `${e.marketPrice}\u00A2` : '?';
203
+ const thesisStr = typeof e.thesisPrice === 'number' ? `${e.thesisPrice}\u00A2` : '?';
204
+ const edgeVal = typeof e.edge === 'number' ? (e.edge > 0 ? `+${e.edge}` : `${e.edge}`) : '?';
205
+ const ob = e.orderbook || {};
206
+ const spreadStr = typeof ob.spread === 'number' ? `${ob.spread}\u00A2` : '?';
207
+ const liq = ob.liquidityScore || 'low';
208
+ const liqBars = liq === 'high' ? '\u25A0\u25A0\u25A0' : liq === 'medium' ? '\u25A0\u25A0 ' : '\u25A0 ';
209
+ const liqColor = liq === 'high' ? C.emerald : liq === 'medium' ? C.amber : C.red;
210
+ // Check if we have a position on this edge (match by marketId prefix in ticker)
211
+ const pos = positions.find((p) => p.ticker === e.marketId ||
212
+ (e.marketId && p.ticker?.includes(e.marketId)));
213
+ let posStr = C.zinc600('\u2014');
214
+ if (pos) {
215
+ const side = pos.side?.toUpperCase() || 'YES';
216
+ const pnl = typeof pos.unrealized_pnl === 'number'
217
+ ? (pos.unrealized_pnl >= 0 ? C.emerald(`+$${(pos.unrealized_pnl / 100).toFixed(0)}`) : C.red(`-$${(Math.abs(pos.unrealized_pnl) / 100).toFixed(0)}`))
218
+ : '';
219
+ posStr = C.emerald(`${side} (${pos.quantity}@${pos.average_price_paid}\u00A2 ${pnl})`);
220
+ }
221
+ 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}`);
222
+ }
223
+ return lines.join('\n');
224
+ }
225
+ function renderPositions(positions) {
226
+ if (!positions?.length)
227
+ return C.zinc600(' No positions');
228
+ const lines = [];
229
+ let totalPnl = 0;
230
+ for (const p of positions) {
231
+ const ticker = (p.ticker || '').slice(0, 18).padEnd(18);
232
+ const side = (p.side || 'yes').toUpperCase().padEnd(3);
233
+ const qty = String(p.quantity || 0);
234
+ const avg = `${p.average_price_paid || 0}\u00A2`;
235
+ const now = typeof p.current_value === 'number' && p.current_value > 0
236
+ ? `${p.current_value}\u00A2`
237
+ : '?\u00A2';
238
+ const pnlCents = p.unrealized_pnl || 0;
239
+ totalPnl += pnlCents;
240
+ const pnlDollars = (pnlCents / 100).toFixed(2);
241
+ const pnlStr = pnlCents >= 0
242
+ ? C.emerald(`+$${pnlDollars}`)
243
+ : C.red(`-$${Math.abs(parseFloat(pnlDollars)).toFixed(2)}`);
244
+ const arrow = pnlCents >= 0 ? C.emerald('\u25B2') : C.red('\u25BC');
245
+ lines.push(` ${C.zinc200(ticker)} ${C.zinc400(side)} ${C.zinc400(qty)} @ ${C.zinc400(avg)} now ${C.zinc200(now)} ${pnlStr} ${arrow}`);
246
+ }
247
+ const totalDollars = (totalPnl / 100).toFixed(2);
248
+ lines.push(C.zinc600(' ' + '\u2500'.repeat(40)));
249
+ lines.push(totalPnl >= 0
250
+ ? ` Total P&L: ${C.emerald(bold(`+$${totalDollars}`))}`
251
+ : ` Total P&L: ${C.red(bold(`-$${Math.abs(parseFloat(totalDollars)).toFixed(2)}`))}`);
252
+ return lines.join('\n');
253
+ }
254
+ // ─── Main command ────────────────────────────────────────────────────────────
24
255
  async function agentCommand(thesisId, opts) {
25
- // ── Dynamic imports for ESM-only packages ──────────────────────────────────
256
+ // ── Dynamic imports (all ESM-only packages) ────────────────────────────────
257
+ const piTui = await import('@mariozechner/pi-tui');
26
258
  const piAi = await import('@mariozechner/pi-ai');
27
259
  const piAgent = await import('@mariozechner/pi-agent-core');
260
+ const { TUI, ProcessTerminal, Container, Text, Markdown, Editor, Loader, Spacer, CombinedAutocompleteProvider, truncateToWidth, visibleWidth, } = piTui;
28
261
  const { getModel, streamSimple, Type } = piAi;
29
262
  const { Agent } = piAgent;
263
+ // ── Component class factories (need piTui ref) ─────────────────────────────
264
+ const MutableLine = createMutableLine(piTui);
265
+ const HeaderBar = createHeaderBar(piTui);
266
+ const FooterBar = createFooterBar(piTui);
30
267
  // ── Validate API keys ──────────────────────────────────────────────────────
31
268
  const openrouterKey = opts?.modelKey || process.env.OPENROUTER_API_KEY;
32
269
  if (!openrouterKey) {
@@ -47,8 +284,124 @@ async function agentCommand(thesisId, opts) {
47
284
  resolvedThesisId = active.id;
48
285
  }
49
286
  // ── Fetch initial context ──────────────────────────────────────────────────
50
- const context = await sfClient.getContext(resolvedThesisId);
51
- // ── Define tools ───────────────────────────────────────────────────────────
287
+ let latestContext = await sfClient.getContext(resolvedThesisId);
288
+ // ── Model setup ────────────────────────────────────────────────────────────
289
+ const rawModelName = opts?.model || 'anthropic/claude-sonnet-4.6';
290
+ let currentModelName = rawModelName.replace(/^openrouter\//, '');
291
+ function resolveModel(name) {
292
+ try {
293
+ return getModel('openrouter', name);
294
+ }
295
+ catch {
296
+ return {
297
+ modelId: name,
298
+ provider: 'openrouter',
299
+ api: 'openai-completions',
300
+ baseUrl: 'https://openrouter.ai/api/v1',
301
+ id: name,
302
+ name: name,
303
+ inputPrice: 0,
304
+ outputPrice: 0,
305
+ contextWindow: 200000,
306
+ supportsImages: true,
307
+ supportsTools: true,
308
+ };
309
+ }
310
+ }
311
+ let model = resolveModel(currentModelName);
312
+ // ── Tracking state ─────────────────────────────────────────────────────────
313
+ let totalTokens = 0;
314
+ let totalCost = 0;
315
+ let totalToolCalls = 0;
316
+ let isProcessing = false;
317
+ // Cache for positions (fetched by /pos or get_positions tool)
318
+ let cachedPositions = null;
319
+ // ── Setup TUI ──────────────────────────────────────────────────────────────
320
+ const terminal = new ProcessTerminal();
321
+ const tui = new TUI(terminal);
322
+ // Markdown theme for assistant messages
323
+ const mdTheme = {
324
+ heading: (s) => C.zinc200(bold(s)),
325
+ link: (s) => C.emerald(s),
326
+ linkUrl: (s) => C.zinc600(s),
327
+ code: (s) => C.zinc200(s),
328
+ codeBlock: (s) => C.zinc400(s),
329
+ codeBlockBorder: (s) => C.zinc600(s),
330
+ quote: (s) => C.zinc400(s),
331
+ quoteBorder: (s) => C.zinc600(s),
332
+ hr: (s) => C.zinc600(s),
333
+ listBullet: (s) => C.emerald(s),
334
+ bold: (s) => bold(s),
335
+ italic: (s) => italic(s),
336
+ strikethrough: (s) => strikethrough(s),
337
+ underline: (s) => underline(s),
338
+ };
339
+ const mdDefaultStyle = {
340
+ color: (s) => C.zinc400(s),
341
+ };
342
+ // Editor theme
343
+ const editorTheme = {
344
+ borderColor: (s) => C.zinc800(s),
345
+ selectList: {
346
+ selectedPrefix: (s) => C.emerald(s),
347
+ selectedText: (s) => C.zinc200(s),
348
+ description: (s) => C.zinc600(s),
349
+ scrollInfo: (s) => C.zinc600(s),
350
+ noMatch: (s) => C.zinc600(s),
351
+ },
352
+ };
353
+ // ── Build components ───────────────────────────────────────────────────────
354
+ const shortId = (resolvedThesisId || '').slice(0, 8);
355
+ const confidencePct = typeof latestContext.confidence === 'number'
356
+ ? Math.round(latestContext.confidence * 100)
357
+ : (typeof latestContext.confidence === 'string' ? parseInt(latestContext.confidence) : 0);
358
+ const headerBar = new HeaderBar(C.emerald(bold('SF Agent')) + C.zinc600(` \u2014 ${shortId}`), confidencePct > 0 ? C.zinc200(`${confidencePct}%`) : '', C.zinc600(currentModelName.split('/').pop() || currentModelName));
359
+ const footerBar = new FooterBar();
360
+ const topSpacer = new Spacer(1);
361
+ const bottomSpacer = new Spacer(1);
362
+ const chatContainer = new Container();
363
+ const editor = new Editor(tui, editorTheme, { paddingX: 1 });
364
+ // Slash command autocomplete
365
+ const autocompleteProvider = new CombinedAutocompleteProvider([
366
+ { name: 'help', description: 'Show available commands' },
367
+ { name: 'tree', description: 'Display causal tree' },
368
+ { name: 'edges', description: 'Display edge/spread table' },
369
+ { name: 'pos', description: 'Display Kalshi positions' },
370
+ { name: 'eval', description: 'Trigger deep evaluation' },
371
+ { name: 'model', description: 'Switch model (e.g. /model anthropic/claude-sonnet-4)' },
372
+ { name: 'clear', description: 'Clear chat' },
373
+ { name: 'exit', description: 'Exit agent' },
374
+ ], process.cwd());
375
+ editor.setAutocompleteProvider(autocompleteProvider);
376
+ // Assemble TUI tree
377
+ tui.addChild(topSpacer);
378
+ tui.addChild(chatContainer);
379
+ tui.addChild(editor);
380
+ tui.addChild(bottomSpacer);
381
+ // Focus on editor
382
+ tui.setFocus(editor);
383
+ // ── Overlays (pinned header + footer) ──────────────────────────────────────
384
+ const headerOverlay = tui.showOverlay(headerBar, {
385
+ row: 0,
386
+ col: 0,
387
+ width: '100%',
388
+ nonCapturing: true,
389
+ });
390
+ const footerOverlay = tui.showOverlay(footerBar, {
391
+ anchor: 'bottom-left',
392
+ width: '100%',
393
+ nonCapturing: true,
394
+ });
395
+ // ── Helper: add system text to chat ────────────────────────────────────────
396
+ function addSystemText(content) {
397
+ const text = new Text(content, 1, 0);
398
+ chatContainer.addChild(text);
399
+ tui.requestRender();
400
+ }
401
+ function addSpacer() {
402
+ chatContainer.addChild(new Spacer(1));
403
+ }
404
+ // ── Define agent tools (same as before) ────────────────────────────────────
52
405
  const thesisIdParam = Type.Object({
53
406
  thesisId: Type.String({ description: 'Thesis ID (short or full UUID)' }),
54
407
  });
@@ -71,6 +424,13 @@ async function agentCommand(thesisId, opts) {
71
424
  parameters: thesisIdParam,
72
425
  execute: async (_toolCallId, params) => {
73
426
  const ctx = await sfClient.getContext(params.thesisId);
427
+ latestContext = ctx;
428
+ // Update header with new confidence
429
+ const conf = typeof ctx.confidence === 'number'
430
+ ? Math.round(ctx.confidence * 100)
431
+ : 0;
432
+ headerBar.update(undefined, conf > 0 ? C.zinc200(`${conf}%`) : '', undefined);
433
+ tui.requestRender();
74
434
  return {
75
435
  content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }],
76
436
  details: {},
@@ -150,22 +510,16 @@ async function agentCommand(thesisId, opts) {
150
510
  {
151
511
  name: 'get_positions',
152
512
  label: 'Get Positions',
153
- description: 'Get Kalshi exchange positions with live prices and PnL (requires local Kalshi key config)',
513
+ description: 'Get Kalshi exchange positions with live prices and PnL',
154
514
  parameters: emptyParams,
155
515
  execute: async () => {
156
516
  const positions = await (0, kalshi_js_1.getPositions)();
157
517
  if (!positions) {
158
518
  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
- ],
519
+ content: [{ type: 'text', text: 'Kalshi not configured. Set KALSHI_API_KEY_ID and KALSHI_PRIVATE_KEY_PATH.' }],
165
520
  details: {},
166
521
  };
167
522
  }
168
- // Enrich with live prices (same logic as sf positions command)
169
523
  for (const pos of positions) {
170
524
  const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
171
525
  if (livePrice !== null) {
@@ -173,6 +527,7 @@ async function agentCommand(thesisId, opts) {
173
527
  pos.unrealized_pnl = Math.round((livePrice - pos.average_price_paid) * pos.quantity);
174
528
  }
175
529
  }
530
+ cachedPositions = positions;
176
531
  return {
177
532
  content: [{ type: 'text', text: JSON.stringify(positions, null, 2) }],
178
533
  details: {},
@@ -181,47 +536,16 @@ async function agentCommand(thesisId, opts) {
181
536
  },
182
537
  ];
183
538
  // ── System prompt ──────────────────────────────────────────────────────────
184
- const confidencePct = typeof context.confidence === 'number'
185
- ? Math.round(context.confidence * 100)
186
- : context.confidence;
187
539
  const systemPrompt = `You are a SimpleFunctions prediction market trading assistant.
188
540
 
189
- Current thesis: ${context.thesis || context.rawThesis || 'N/A'}
541
+ Current thesis: ${latestContext.thesis || latestContext.rawThesis || 'N/A'}
190
542
  Confidence: ${confidencePct}%
191
- Status: ${context.status}
192
- Thesis ID: ${context.thesisId || resolvedThesisId}
543
+ Status: ${latestContext.status}
544
+ Thesis ID: ${latestContext.thesisId || resolvedThesisId}
193
545
 
194
546
  You have six tools available. Use them when you need real-time data. Answer directly when you don't.
195
547
  Be concise. Use Chinese if the user writes in Chinese, English if they write in English.
196
548
  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
549
  // ── Create Agent ───────────────────────────────────────────────────────────
226
550
  const agent = new Agent({
227
551
  initialState: {
@@ -237,54 +561,289 @@ Do NOT make up data. Always call tools to get current state.`;
237
561
  return undefined;
238
562
  },
239
563
  });
240
- // ── Subscribe to streaming events ──────────────────────────────────────────
564
+ // ── Subscribe to agent events → update TUI ────────────────────────────────
565
+ let currentAssistantMd = null;
566
+ let currentAssistantText = '';
567
+ let currentLoader = null;
568
+ const toolStartTimes = new Map();
569
+ const toolLines = new Map();
570
+ // Throttle renders during streaming to prevent flicker (max ~15fps)
571
+ let renderTimer = null;
572
+ function throttledRender() {
573
+ if (renderTimer)
574
+ return;
575
+ renderTimer = setTimeout(() => {
576
+ renderTimer = null;
577
+ tui.requestRender();
578
+ }, 66);
579
+ }
580
+ function flushRender() {
581
+ if (renderTimer) {
582
+ clearTimeout(renderTimer);
583
+ renderTimer = null;
584
+ }
585
+ tui.requestRender();
586
+ }
241
587
  agent.subscribe((event) => {
588
+ if (event.type === 'message_start') {
589
+ // Show loader while waiting for first text
590
+ currentAssistantText = '';
591
+ currentAssistantMd = null;
592
+ currentLoader = new Loader(tui, (s) => C.emerald(s), (s) => C.zinc600(s), 'thinking...');
593
+ currentLoader.start();
594
+ chatContainer.addChild(currentLoader);
595
+ tui.requestRender();
596
+ }
242
597
  if (event.type === 'message_update') {
243
598
  const e = event.assistantMessageEvent;
244
599
  if (e.type === 'text_delta') {
245
- process.stdout.write(e.delta);
600
+ // Remove loader on first text delta
601
+ if (currentLoader) {
602
+ currentLoader.stop();
603
+ chatContainer.removeChild(currentLoader);
604
+ currentLoader = null;
605
+ // Create markdown component for assistant response
606
+ currentAssistantMd = new Markdown('', 1, 0, mdTheme, mdDefaultStyle);
607
+ chatContainer.addChild(currentAssistantMd);
608
+ }
609
+ currentAssistantText += e.delta;
610
+ if (currentAssistantMd) {
611
+ currentAssistantMd.setText(currentAssistantText);
612
+ }
613
+ // Throttled render to prevent flicker during fast token streaming
614
+ throttledRender();
246
615
  }
247
616
  }
617
+ if (event.type === 'message_end') {
618
+ // Clean up loader if still present (no text was generated)
619
+ if (currentLoader) {
620
+ currentLoader.stop();
621
+ chatContainer.removeChild(currentLoader);
622
+ currentLoader = null;
623
+ }
624
+ // Final render of the complete message
625
+ if (currentAssistantMd && currentAssistantText) {
626
+ currentAssistantMd.setText(currentAssistantText);
627
+ }
628
+ addSpacer();
629
+ currentAssistantMd = null;
630
+ currentAssistantText = '';
631
+ flushRender();
632
+ }
633
+ if (event.type === 'agent_end') {
634
+ // Agent turn fully complete — safe to accept new input
635
+ isProcessing = false;
636
+ flushRender();
637
+ }
248
638
  if (event.type === 'tool_execution_start') {
249
- process.stdout.write(`\n\x1b[33m⚡ ${event.toolName}\x1b[0m `);
639
+ const toolLine = new MutableLine(C.zinc600(` \u26A1 ${event.toolName}...`));
640
+ toolStartTimes.set(event.toolCallId || event.toolName, Date.now());
641
+ toolLines.set(event.toolCallId || event.toolName, toolLine);
642
+ chatContainer.addChild(toolLine);
643
+ totalToolCalls++;
644
+ footerBar.toolCount = totalToolCalls;
645
+ footerBar.update();
646
+ tui.requestRender();
250
647
  }
251
648
  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');
649
+ const key = event.toolCallId || event.toolName;
650
+ const startTime = toolStartTimes.get(key);
651
+ const elapsed = startTime ? ((Date.now() - startTime) / 1000).toFixed(1) : '?';
652
+ const line = toolLines.get(key);
653
+ if (line) {
654
+ if (event.isError) {
655
+ line.setText(C.red(` \u2717 ${event.toolName} (${elapsed}s) error`));
656
+ }
657
+ else {
658
+ line.setText(C.zinc600(` \u26A1 ${event.toolName}`) + C.emerald(` \u2713`) + C.zinc600(` (${elapsed}s)`));
659
+ }
257
660
  }
661
+ toolStartTimes.delete(key);
662
+ toolLines.delete(key);
663
+ tui.requestRender();
258
664
  }
259
665
  });
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;
666
+ // ── Slash command handlers ─────────────────────────────────────────────────
667
+ async function handleSlashCommand(cmd) {
668
+ const parts = cmd.trim().split(/\s+/);
669
+ const command = parts[0].toLowerCase();
670
+ switch (command) {
671
+ case '/help': {
672
+ addSpacer();
673
+ addSystemText(C.zinc200(bold('Commands')) + '\n' +
674
+ C.emerald('/help ') + C.zinc400(' Show this help') + '\n' +
675
+ C.emerald('/tree ') + C.zinc400(' Display causal tree') + '\n' +
676
+ C.emerald('/edges ') + C.zinc400(' Display edge/spread table') + '\n' +
677
+ C.emerald('/pos ') + C.zinc400(' Display Kalshi positions') + '\n' +
678
+ C.emerald('/eval ') + C.zinc400(' Trigger deep evaluation') + '\n' +
679
+ C.emerald('/model ') + C.zinc400(' Switch model (e.g. /model anthropic/claude-sonnet-4)') + '\n' +
680
+ C.emerald('/clear ') + C.zinc400(' Clear chat') + '\n' +
681
+ C.emerald('/exit ') + C.zinc400(' Exit agent'));
682
+ addSpacer();
683
+ return true;
274
684
  }
275
- if (trimmed === 'exit' || trimmed === 'quit') {
276
- rl.close();
277
- process.exit(0);
685
+ case '/tree': {
686
+ addSpacer();
687
+ // Refresh context first
688
+ try {
689
+ latestContext = await sfClient.getContext(resolvedThesisId);
690
+ addSystemText(C.zinc200(bold('Causal Tree')) + '\n' + renderCausalTree(latestContext, piTui));
691
+ }
692
+ catch (err) {
693
+ addSystemText(C.red(`Error: ${err.message}`));
694
+ }
695
+ addSpacer();
696
+ return true;
278
697
  }
279
- try {
280
- await agent.prompt(trimmed);
281
- console.log('\n');
698
+ case '/edges': {
699
+ addSpacer();
700
+ try {
701
+ latestContext = await sfClient.getContext(resolvedThesisId);
702
+ // Attach cached positions for display
703
+ if (cachedPositions) {
704
+ latestContext._positions = cachedPositions;
705
+ }
706
+ addSystemText(C.zinc200(bold('Edges')) + '\n' + renderEdges(latestContext, piTui));
707
+ }
708
+ catch (err) {
709
+ addSystemText(C.red(`Error: ${err.message}`));
710
+ }
711
+ addSpacer();
712
+ return true;
282
713
  }
283
- catch (err) {
284
- console.error(`\n\x1b[31mError: ${err.message}\x1b[0m\n`);
714
+ case '/pos': {
715
+ addSpacer();
716
+ try {
717
+ const positions = await (0, kalshi_js_1.getPositions)();
718
+ if (!positions) {
719
+ addSystemText(C.zinc600('Kalshi not configured'));
720
+ return true;
721
+ }
722
+ for (const pos of positions) {
723
+ const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
724
+ if (livePrice !== null) {
725
+ pos.current_value = livePrice;
726
+ pos.unrealized_pnl = Math.round((livePrice - pos.average_price_paid) * pos.quantity);
727
+ }
728
+ }
729
+ cachedPositions = positions;
730
+ addSystemText(C.zinc200(bold('Positions')) + '\n' + renderPositions(positions));
731
+ }
732
+ catch (err) {
733
+ addSystemText(C.red(`Error: ${err.message}`));
734
+ }
735
+ addSpacer();
736
+ return true;
737
+ }
738
+ case '/eval': {
739
+ addSpacer();
740
+ addSystemText(C.zinc600('Triggering evaluation...'));
741
+ tui.requestRender();
742
+ try {
743
+ const result = await sfClient.evaluate(resolvedThesisId);
744
+ addSystemText(C.emerald('Evaluation complete') + '\n' + C.zinc400(JSON.stringify(result, null, 2)));
745
+ }
746
+ catch (err) {
747
+ addSystemText(C.red(`Error: ${err.message}`));
748
+ }
749
+ addSpacer();
750
+ return true;
751
+ }
752
+ case '/model': {
753
+ const newModel = parts.slice(1).join(' ').trim();
754
+ if (!newModel) {
755
+ addSystemText(C.zinc400(`Current model: ${currentModelName}`));
756
+ return true;
757
+ }
758
+ addSpacer();
759
+ currentModelName = newModel.replace(/^openrouter\//, '');
760
+ model = resolveModel(currentModelName);
761
+ // Update agent model
762
+ agent.setModel(model);
763
+ headerBar.update(undefined, undefined, C.zinc600(currentModelName.split('/').pop() || currentModelName));
764
+ addSystemText(C.emerald(`Model switched to ${currentModelName}`));
765
+ addSpacer();
766
+ tui.requestRender();
767
+ return true;
768
+ }
769
+ case '/clear': {
770
+ chatContainer.clear();
771
+ tui.requestRender();
772
+ return true;
773
+ }
774
+ case '/exit':
775
+ case '/quit': {
776
+ cleanup();
777
+ return true;
285
778
  }
286
- prompt();
287
- });
779
+ default:
780
+ return false;
781
+ }
782
+ }
783
+ // ── Editor submit handler ──────────────────────────────────────────────────
784
+ editor.onSubmit = async (input) => {
785
+ const trimmed = input.trim();
786
+ if (!trimmed)
787
+ return;
788
+ if (isProcessing)
789
+ return;
790
+ // Add to editor history
791
+ editor.addToHistory(trimmed);
792
+ // Check for slash commands
793
+ if (trimmed.startsWith('/')) {
794
+ const handled = await handleSlashCommand(trimmed);
795
+ if (handled)
796
+ return;
797
+ }
798
+ // Regular message → send to agent
799
+ isProcessing = true;
800
+ // Add user message to chat
801
+ const userMsg = new Text(C.emerald(bold('>')) + ' ' + C.white(trimmed), 1, 0);
802
+ chatContainer.addChild(userMsg);
803
+ addSpacer();
804
+ tui.requestRender();
805
+ try {
806
+ await agent.prompt(trimmed);
807
+ }
808
+ catch (err) {
809
+ // Remove loader if present
810
+ if (currentLoader) {
811
+ currentLoader.stop();
812
+ chatContainer.removeChild(currentLoader);
813
+ currentLoader = null;
814
+ }
815
+ addSystemText(C.red(`Error: ${err.message}`));
816
+ addSpacer();
817
+ isProcessing = false;
818
+ }
288
819
  };
289
- prompt();
820
+ // ── Ctrl+C handler ─────────────────────────────────────────────────────────
821
+ function cleanup() {
822
+ if (currentLoader)
823
+ currentLoader.stop();
824
+ tui.stop();
825
+ process.exit(0);
826
+ }
827
+ // Listen for Ctrl+C at the TUI level
828
+ tui.addInputListener((data) => {
829
+ // Ctrl+C = \x03
830
+ if (data === '\x03') {
831
+ cleanup();
832
+ return { consume: true };
833
+ }
834
+ return undefined;
835
+ });
836
+ // Also handle SIGINT
837
+ process.on('SIGINT', cleanup);
838
+ process.on('SIGTERM', cleanup);
839
+ // ── Show initial welcome ───────────────────────────────────────────────────
840
+ const thesisText = latestContext.thesis || latestContext.rawThesis || 'N/A';
841
+ const truncatedThesis = thesisText.length > 120 ? thesisText.slice(0, 120) + '...' : thesisText;
842
+ addSystemText(C.zinc600('\u2500'.repeat(50)) + '\n' +
843
+ C.zinc200(bold(truncatedThesis)) + '\n' +
844
+ C.zinc600(`${latestContext.status || 'active'} ${confidencePct > 0 ? confidencePct + '%' : ''} ${(latestContext.edges || []).length} edges`) + '\n' +
845
+ C.zinc600('\u2500'.repeat(50)));
846
+ addSpacer();
847
+ // ── Start TUI ──────────────────────────────────────────────────────────────
848
+ tui.start();
290
849
  }
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "@spfunctions/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
  },