horizon-code 0.3.0 → 0.3.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "horizon-code",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "AI-powered trading strategy terminal for Polymarket",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.ts CHANGED
@@ -638,6 +638,11 @@ export class App {
638
638
  if (loginResult.success) {
639
639
  this.authenticated = true;
640
640
  this.showSystemMsg(`Logged in as ${loginResult.email}`);
641
+ // Start session sync + platform sync now that we have a live session
642
+ import("./platform/session-sync.ts").then(({ loadSessions, startAutoSave }) => {
643
+ loadSessions().catch(() => {});
644
+ startAutoSave();
645
+ }).catch(() => {});
641
646
  import("./platform/sync.ts").then(({ platformSync }) => {
642
647
  platformSync.start(30000).catch(() => {});
643
648
  });
@@ -934,7 +939,6 @@ export class App {
934
939
 
935
940
  if (!live) {
936
941
  this.splash.setAuthStatus(true, email);
937
- this.showSystemMsg("Your session has expired. Type /login to sign in again.");
938
942
  } else {
939
943
  this.splash.setAuthStatus(true, email, firstTime);
940
944
  }
@@ -85,7 +85,7 @@ export class ChatRenderer {
85
85
  id: `msg-${message.id}`,
86
86
  flexDirection: "column",
87
87
  width: "100%",
88
- backgroundColor: isUser ? COLORS.selection : undefined,
88
+ backgroundColor: undefined,
89
89
  paddingLeft: 1,
90
90
  paddingRight: 1,
91
91
  marginTop: 1,
@@ -386,7 +386,7 @@ export class CodePanel {
386
386
  : id === "dashboard" ? "Metrics"
387
387
  : id.charAt(0).toUpperCase() + id.slice(1);
388
388
  if (id === this._activeTab) {
389
- text.fg = "#212121";
389
+ text.fg = COLORS.bg;
390
390
  text.bg = COLORS.accent;
391
391
  text.content = ` ${label} `;
392
392
  } else {
@@ -35,7 +35,7 @@ export class HooksPanel {
35
35
  backgroundColor: COLORS.bgDarker, flexShrink: 0,
36
36
  });
37
37
  header.add(new TextRenderable(renderer, {
38
- id: "hooks-title", content: " Hooks ", fg: "#212121", bg: COLORS.accent,
38
+ id: "hooks-title", content: " Hooks ", fg: COLORS.bg, bg: COLORS.accent,
39
39
  }));
40
40
  this.container.add(header);
41
41
 
@@ -181,7 +181,7 @@ export class ModeBar {
181
181
  private updateDisplay(): void {
182
182
  const m = MODE_CONFIG[this._current];
183
183
  this.modeText.content = ` ${m.icon} ${m.label} `;
184
- this.modeText.fg = "#212121";
184
+ this.modeText.fg = COLORS.bg;
185
185
  this.modeText.bg = m.color;
186
186
  this.updateStatus();
187
187
  this.updateMetricsDisplay();
@@ -153,7 +153,7 @@ export class SettingsPanel {
153
153
  header.add(new TextRenderable(renderer, {
154
154
  id: "settings-title",
155
155
  content: " Settings ",
156
- fg: "#212121",
156
+ fg: COLORS.bg,
157
157
  bg: COLORS.accent,
158
158
  }));
159
159
  this.container.add(header);
@@ -27,7 +27,6 @@ export class Splash {
27
27
  top: 0,
28
28
  width: "100%",
29
29
  height: "100%",
30
- backgroundColor: COLORS.bg,
31
30
  flexDirection: "column",
32
31
  alignItems: "center",
33
32
  justifyContent: "center",
@@ -11,11 +11,14 @@ const MODE_LABEL: Record<string, string> = {
11
11
  portfolio: "(p)",
12
12
  };
13
13
 
14
- const MODE_COLOR: Record<string, string> = {
15
- research: "#5c9cf5", // COLORS.secondary
16
- strategy: "#9d7cd8", // COLORS.accent
17
- portfolio: "#7fd88f", // COLORS.success
18
- };
14
+ function getModeColor(mode: string): string {
15
+ switch (mode) {
16
+ case "research": return COLORS.secondary;
17
+ case "strategy": return COLORS.accent;
18
+ case "portfolio": return COLORS.success;
19
+ default: return COLORS.secondary;
20
+ }
21
+ }
19
22
 
20
23
  const BRAILLE = ["\u2801", "\u2803", "\u2807", "\u280f", "\u281f", "\u283f", "\u287f", "\u28ff", "\u28fe", "\u28fc", "\u28f8", "\u28f0", "\u28e0", "\u28c0", "\u2880", "\u2800"];
21
24
 
@@ -53,7 +56,7 @@ export class TabBar {
53
56
 
54
57
  const isActive = tabId === activeSessionId;
55
58
  const modeTag = MODE_LABEL[session.mode] ?? "(r)";
56
- const modeColor = MODE_COLOR[session.mode] ?? COLORS.secondary;
59
+ const modeColor = getModeColor(session.mode);
57
60
 
58
61
  // Truncate name
59
62
  let name = session.name.length > 12 ? session.name.slice(0, 11) + "." : session.name;
@@ -69,7 +72,7 @@ export class TabBar {
69
72
  const tab = new TextRenderable(this.renderer, {
70
73
  id: `tab-${i}`,
71
74
  content: label,
72
- fg: isActive ? "#212121" : COLORS.textMuted,
75
+ fg: isActive ? COLORS.bg : COLORS.textMuted,
73
76
  bg: isActive ? modeColor : undefined,
74
77
  });
75
78
  this.container.add(tab);
@@ -667,7 +667,7 @@ export class TutorialPanel {
667
667
  private updateTabBar(): void {
668
668
  for (const [id, text] of this.tabTexts) {
669
669
  if (id === this._activeTab) {
670
- text.fg = "#212121";
670
+ text.fg = COLORS.bg;
671
671
  text.bg = COLORS.secondary;
672
672
  text.content = ` ${TAB_LIST.find((t) => t.id === id)!.label} `;
673
673
  } else {
@@ -114,20 +114,38 @@ export async function restoreSession(): Promise<boolean> {
114
114
  }
115
115
  }
116
116
 
117
- const { data, error } = await sb.auth.setSession({
118
- access_token: accessToken,
119
- refresh_token: refreshToken,
120
- });
117
+ // Try setSession first (works if access token still valid)
118
+ try {
119
+ const { data, error } = await sb.auth.setSession({
120
+ access_token: accessToken,
121
+ refresh_token: refreshToken,
122
+ });
123
+
124
+ if (!error && data.session) {
125
+ if (config.api_key) platform.setApiKey(config.api_key);
126
+ return true;
127
+ }
128
+ } catch {}
121
129
 
122
- if (!error && data.session) {
123
- // Session restored — auto-refresh will keep it alive
124
- if (config.api_key) platform.setApiKey(config.api_key);
125
- return true;
126
- }
130
+ // Access token expired — try refreshing with just the refresh token
131
+ try {
132
+ const { data: refreshData, error: refreshError } = await sb.auth.refreshSession({
133
+ refresh_token: refreshToken,
134
+ });
135
+
136
+ if (!refreshError && refreshData.session) {
137
+ // Save the new tokens
138
+ saveSessionTokens(config, refreshData.session.access_token, refreshData.session.refresh_token);
139
+ config.user_email = refreshData.session.user.email ?? config.user_email;
140
+ config.user_id = refreshData.session.user.id;
141
+ saveConfig(config);
142
+ if (config.api_key) platform.setApiKey(config.api_key);
143
+ return true;
144
+ }
145
+ } catch {}
127
146
 
128
- // Tokens fully expired user needs to /login again
129
- delete config.supabase_session;
130
- saveConfig(config);
147
+ // Both faileddon't delete the session, it might work next time
148
+ // (network issue, Supabase outage, etc.). The API key still works for chat.
131
149
  }
132
150
 
133
151
  // API key still works for the LLM proxy even without a Supabase session
@@ -312,53 +312,134 @@ function parseKalshiMarket(m: any): any {
312
312
  let _kalshiCache: { data: any[]; ts: number } = { data: [], ts: 0 };
313
313
  const KALSHI_CACHE_TTL = 60_000;
314
314
 
315
- async function fetchAllKalshiEvents(limit = 100): Promise<any[]> {
315
+ /** Fetch events from Kalshi uses cursor pagination to get more results */
316
+ async function fetchKalshiEvents(limit = 200): Promise<any[]> {
316
317
  if (_kalshiCache.data.length > 0 && Date.now() - _kalshiCache.ts < KALSHI_CACHE_TTL) {
317
318
  return _kalshiCache.data;
318
319
  }
319
- const params = new URLSearchParams({
320
- status: "open", with_nested_markets: "true",
321
- limit: String(limit),
322
- });
323
- const data = await get(`${KALSHI}/events?${params}`).catch(() => ({ events: [] }));
324
- const events = data?.events ?? [];
325
- _kalshiCache = { data: events, ts: Date.now() };
326
- return events;
320
+
321
+ const all: any[] = [];
322
+ let cursor = "";
323
+ const batchSize = 100;
324
+
325
+ // Fetch up to `limit` events across pages
326
+ while (all.length < limit) {
327
+ const params = new URLSearchParams({
328
+ status: "open", with_nested_markets: "true",
329
+ limit: String(Math.min(batchSize, limit - all.length)),
330
+ });
331
+ if (cursor) params.set("cursor", cursor);
332
+
333
+ const data = await get(`${KALSHI}/events?${params}`).catch(() => ({ events: [], cursor: "" }));
334
+ const events = data?.events ?? [];
335
+ if (events.length === 0) break;
336
+
337
+ all.push(...events);
338
+ cursor = data?.cursor ?? "";
339
+ if (!cursor) break;
340
+ }
341
+
342
+ _kalshiCache = { data: all, ts: Date.now() };
343
+ return all;
327
344
  }
328
345
 
346
+ /** Build searchable text from a Kalshi event (including nested market titles) */
347
+ function kalshiSearchText(e: any): string {
348
+ const parts = [e.title, e.sub_title, e.category, e.series_ticker, e.event_ticker];
349
+ // Include nested market titles for better search
350
+ for (const m of (e.markets ?? [])) {
351
+ parts.push(m.title, m.subtitle, m.yes_sub_title, m.no_sub_title, m.ticker);
352
+ }
353
+ return parts.filter(Boolean).join(" ").toLowerCase();
354
+ }
355
+
356
+ // Common series tickers for keyword-to-series mapping
357
+ const KALSHI_SERIES_MAP: Record<string, string> = {
358
+ bitcoin: "KXBTC", btc: "KXBTC", crypto: "KXBTC",
359
+ ethereum: "KXETH", eth: "KXETH",
360
+ nasdaq: "KXNASDAQ", spy: "KXSPY", sp500: "KXSPY", "s&p": "KXSPY",
361
+ fed: "KXFED", "interest rate": "KXFED", fomc: "KXFED",
362
+ cpi: "KXCPI", inflation: "KXCPI",
363
+ gdp: "KXGDP", unemployment: "KXUNEMP", jobs: "KXUNEMP",
364
+ oil: "KXOIL", gold: "KXGOLD",
365
+ };
366
+
329
367
  export async function kalshiEvents(opts: { query?: string; limit?: number } = {}): Promise<any[]> {
330
368
  const limit = Math.min(opts.limit ?? 10, 20);
331
369
  const query = (opts.query ?? "").toLowerCase().trim();
332
370
 
333
- const allEvents = await fetchAllKalshiEvents();
334
- let events: any[];
335
-
336
371
  if (!query) {
337
- events = allEvents.slice(0, limit);
338
- } else {
339
- // Client-side text search (series_ticker only matches exact tickers)
340
- const words = query.split(/\s+/).filter(w => w.length > 1);
372
+ const allEvents = await fetchKalshiEvents();
373
+ return allEvents.slice(0, limit).map(formatKalshiEvent);
374
+ }
375
+
376
+ // 1. Try series_ticker mapping first (crypto, fed, etc.)
377
+ const seriesTicker = KALSHI_SERIES_MAP[query] ??
378
+ Object.entries(KALSHI_SERIES_MAP).find(([k]) => query.includes(k))?.[1];
379
+
380
+ if (seriesTicker) {
381
+ try {
382
+ const params = new URLSearchParams({
383
+ status: "open", with_nested_markets: "true",
384
+ series_ticker: seriesTicker, limit: String(limit),
385
+ });
386
+ const data = await get(`${KALSHI}/events?${params}`);
387
+ const events = data?.events ?? [];
388
+ if (events.length > 0) return events.slice(0, limit).map(formatKalshiEvent);
389
+ } catch {}
390
+ }
391
+
392
+ // 2. Search cached events (title + market titles)
393
+ const allEvents = await fetchKalshiEvents();
394
+ const words = query.split(/\s+/).filter(w => w.length > 1);
395
+
396
+ let events = allEvents.filter((e: any) => {
397
+ const text = kalshiSearchText(e);
398
+ return words.every(w => text.includes(w));
399
+ });
400
+
401
+ // Fallback: any word match
402
+ if (events.length === 0 && words.length > 1) {
341
403
  events = allEvents.filter((e: any) => {
342
- const text = ((e.title ?? "") + " " + (e.sub_title ?? "") + " " +
343
- (e.category ?? "") + " " + (e.series_ticker ?? "") + " " + (e.event_ticker ?? "")).toLowerCase();
344
- return words.every(w => text.includes(w));
404
+ const text = kalshiSearchText(e);
405
+ return words.some(w => text.includes(w));
345
406
  });
346
- // Fallback: any word match
347
- if (events.length === 0 && words.length > 1) {
348
- events = allEvents.filter((e: any) => {
349
- const text = ((e.title ?? "") + " " + (e.sub_title ?? "") + " " + (e.category ?? "")).toLowerCase();
350
- return words.some(w => text.includes(w));
407
+ }
408
+
409
+ // 3. If still empty, try series_ticker as-is (user might pass a ticker directly)
410
+ if (events.length === 0) {
411
+ try {
412
+ const params = new URLSearchParams({
413
+ status: "open", with_nested_markets: "true",
414
+ series_ticker: query.toUpperCase(), limit: String(limit),
351
415
  });
352
- }
353
- events = events.slice(0, limit);
416
+ const data = await get(`${KALSHI}/events?${params}`);
417
+ events = data?.events ?? [];
418
+ } catch {}
354
419
  }
355
420
 
356
- return events.map((e: any) => ({
421
+ // Sort by relevance
422
+ events.sort((a: any, b: any) => {
423
+ const aTitle = (a.title ?? "").toLowerCase();
424
+ const bTitle = (b.title ?? "").toLowerCase();
425
+ const aInTitle = words.some(w => aTitle.includes(w)) ? 1 : 0;
426
+ const bInTitle = words.some(w => bTitle.includes(w)) ? 1 : 0;
427
+ if (aInTitle !== bInTitle) return bInTitle - aInTitle;
428
+ const aVol = (a.markets ?? []).reduce((s: number, m: any) => s + kVol(m.volume_fp ?? m.volume), 0);
429
+ const bVol = (b.markets ?? []).reduce((s: number, m: any) => s + kVol(m.volume_fp ?? m.volume), 0);
430
+ return bVol - aVol;
431
+ });
432
+
433
+ return events.slice(0, limit).map(formatKalshiEvent);
434
+ }
435
+
436
+ function formatKalshiEvent(e: any): any {
437
+ return {
357
438
  ticker: e.event_ticker, title: e.title,
358
439
  category: e.category, subtitle: e.sub_title,
359
440
  url: `https://kalshi.com/markets/${(e.event_ticker ?? "").toLowerCase()}`,
360
441
  markets: (e.markets ?? []).slice(0, 5).map(parseKalshiMarket),
361
- }));
442
+ };
362
443
  }
363
444
 
364
445
  export async function kalshiEventDetail(ticker: string): Promise<any> {
@@ -7,15 +7,17 @@ import { COLORS } from "../theme/colors.ts";
7
7
  const SPARK = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
8
8
  const BAR = "\u2588";
9
9
 
10
- // Exchange brand colors
11
- const EXCHANGE_COLORS: Record<string, string> = {
12
- polymarket: "#4A90D9", // blue
13
- kalshi: "#22C55E", // green
14
- stock: "#A855F7", // purple
15
- yahoo: "#A855F7", // purple
16
- cross: "#F59E0B", // amber
17
- analysis: "#6366F1", // indigo
18
- };
10
+ // Exchange brand colors — uses theme palette so they adapt to all themes
11
+ function getExchangeColor(exchange: string): string {
12
+ switch (exchange) {
13
+ case "polymarket": return COLORS.secondary; // blue
14
+ case "kalshi": return COLORS.success; // green
15
+ case "stock": case "yahoo": return COLORS.accent; // purple
16
+ case "cross": return COLORS.warning; // amber
17
+ case "analysis": return COLORS.info; // teal/blue
18
+ default: return COLORS.textMuted;
19
+ }
20
+ }
19
21
 
20
22
  /** Format a price for chart Y-axis labels */
21
23
  function fmtChartLabel(v: number): string {
@@ -210,7 +212,7 @@ function renderChart(box: BoxRenderable, renderer: CliRenderer, chart: ChartResu
210
212
  }
211
213
 
212
214
  function exchangeBadge(parent: BoxRenderable, renderer: CliRenderer, exchange: string, title: string): void {
213
- const color = EXCHANGE_COLORS[exchange] ?? COLORS.textMuted;
215
+ const color = getExchangeColor(exchange);
214
216
  const label = exchange.toUpperCase();
215
217
  const row = new BoxRenderable(renderer, { id: uid(), flexDirection: "row" });
216
218
  row.add(new TextRenderable(renderer, { id: uid(), content: `[${label}]`, fg: color, attributes: 1 }));
@@ -19,18 +19,18 @@ interface Colors {
19
19
 
20
20
  const THEMES: Record<ThemeName, Colors> = {
21
21
  dark: {
22
- bg: "#212121", bgSecondary: "#252525", bgDarker: "#1a1a1a", selection: "#303030",
23
- text: "#e0e0e0", textMuted: "#6a6a6a", textEmphasized: "#ffffff",
24
- border: "#4b4c5c", borderDim: "#333333", borderFocus: "#fab283",
25
- primary: "#fab283", primaryDim: "#c48a62", secondary: "#5c9cf5", accent: "#9d7cd8",
26
- success: "#7fd88f", error: "#e06c75", warning: "#f5a742", info: "#56b6c2",
22
+ bg: "#1a1a1a", bgSecondary: "#1e1e1e", bgDarker: "#141414", selection: "#252525",
23
+ text: "#d4d4d4", textMuted: "#808080", textEmphasized: "#ffffff",
24
+ border: "#4b4c5c", borderDim: "#3a3a3a", borderFocus: "#fab283",
25
+ primary: "#fab283", primaryDim: "#c48a62", secondary: "#4d8ef7", accent: "#9d7cd8",
26
+ success: "#7fd88f", error: "#e06c75", warning: "#f5a742", info: "#5bb8d0",
27
27
  },
28
28
  midnight: {
29
- bg: "#0d1117", bgSecondary: "#161b22", bgDarker: "#080c12", selection: "#1c2333",
30
- text: "#c9d1d9", textMuted: "#484f58", textEmphasized: "#f0f6fc",
31
- border: "#30363d", borderDim: "#21262d", borderFocus: "#8be9fd",
32
- primary: "#ff79c6", primaryDim: "#cc5f9e", secondary: "#8be9fd", accent: "#bd93f9",
33
- success: "#50fa7b", error: "#ff5555", warning: "#f1fa8c", info: "#8be9fd",
29
+ bg: "#0d1117", bgSecondary: "#131920", bgDarker: "#080c12", selection: "#182030",
30
+ text: "#c9d1d9", textMuted: "#636e7b", textEmphasized: "#f0f6fc",
31
+ border: "#30363d", borderDim: "#2d333b", borderFocus: "#8be9fd",
32
+ primary: "#ff79c6", primaryDim: "#cc5f9e", secondary: "#79b8ff", accent: "#bd93f9",
33
+ success: "#50fa7b", error: "#ff5555", warning: "#f1fa8c", info: "#79b8ff",
34
34
  },
35
35
  nord: {
36
36
  bg: "#2e3440", bgSecondary: "#3b4252", bgDarker: "#272c36", selection: "#434c5e",
@@ -68,8 +68,8 @@ const THEMES: Record<ThemeName, Colors> = {
68
68
  // ── Ultra: Poly Dark (black and blue) ──
69
69
  "poly-dark": {
70
70
  bg: "#0f1120", bgSecondary: "#151830", bgDarker: "#0a0c18", selection: "#1e2240",
71
- text: "#c8cee0", textMuted: "#5b6080", textEmphasized: "#f0f2ff",
72
- border: "#2a2f50", borderDim: "#1e2240", borderFocus: "#3b82f6",
71
+ text: "#c8cee0", textMuted: "#7278a0", textEmphasized: "#f0f2ff",
72
+ border: "#2a2f50", borderDim: "#252a48", borderFocus: "#3b82f6",
73
73
  primary: "#3b82f6", primaryDim: "#2563eb", secondary: "#6366f1", accent: "#818cf8",
74
74
  success: "#34d399", error: "#f87171", warning: "#fbbf24", info: "#60a5fa",
75
75
  },