horizon-code 0.3.0 → 0.3.1

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.1",
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,11 @@ 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.");
942
+ // Only warn about expired session if they actually had one before
943
+ // Don't warn if they just have an api_key (chat still works fine)
944
+ if (config.supabase_session) {
945
+ this.showSystemMsg("Supabase session expired — chat sync disabled. Type /login to reconnect.");
946
+ }
938
947
  } else {
939
948
  this.splash.setAuthStatus(true, email, firstTime);
940
949
  }
@@ -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);
@@ -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,18 +114,33 @@ export async function restoreSession(): Promise<boolean> {
114
114
  }
115
115
  }
116
116
 
117
+ // Try setSession first (works if access token still valid)
117
118
  const { data, error } = await sb.auth.setSession({
118
119
  access_token: accessToken,
119
120
  refresh_token: refreshToken,
120
121
  });
121
122
 
122
123
  if (!error && data.session) {
123
- // Session restored — auto-refresh will keep it alive
124
124
  if (config.api_key) platform.setApiKey(config.api_key);
125
125
  return true;
126
126
  }
127
127
 
128
- // Tokens fully expired — user needs to /login again
128
+ // Access token expired — try refreshing with just the refresh token
129
+ const { data: refreshData, error: refreshError } = await sb.auth.refreshSession({
130
+ refresh_token: refreshToken,
131
+ });
132
+
133
+ if (!refreshError && refreshData.session) {
134
+ // Save the new tokens
135
+ saveSessionTokens(config, refreshData.session.access_token, refreshData.session.refresh_token);
136
+ config.user_email = refreshData.session.user.email ?? config.user_email;
137
+ config.user_id = refreshData.session.user.id;
138
+ saveConfig(config);
139
+ if (config.api_key) platform.setApiKey(config.api_key);
140
+ return true;
141
+ }
142
+
143
+ // Both failed — refresh token fully expired
129
144
  delete config.supabase_session;
130
145
  saveConfig(config);
131
146
  }
@@ -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 }));
@@ -20,15 +20,15 @@ interface Colors {
20
20
  const THEMES: Record<ThemeName, Colors> = {
21
21
  dark: {
22
22
  bg: "#212121", bgSecondary: "#252525", bgDarker: "#1a1a1a", selection: "#303030",
23
- text: "#e0e0e0", textMuted: "#6a6a6a", textEmphasized: "#ffffff",
24
- border: "#4b4c5c", borderDim: "#333333", borderFocus: "#fab283",
23
+ text: "#e0e0e0", textMuted: "#808080", textEmphasized: "#ffffff",
24
+ border: "#4b4c5c", borderDim: "#444444", borderFocus: "#fab283",
25
25
  primary: "#fab283", primaryDim: "#c48a62", secondary: "#5c9cf5", accent: "#9d7cd8",
26
26
  success: "#7fd88f", error: "#e06c75", warning: "#f5a742", info: "#56b6c2",
27
27
  },
28
28
  midnight: {
29
29
  bg: "#0d1117", bgSecondary: "#161b22", bgDarker: "#080c12", selection: "#1c2333",
30
- text: "#c9d1d9", textMuted: "#484f58", textEmphasized: "#f0f6fc",
31
- border: "#30363d", borderDim: "#21262d", borderFocus: "#8be9fd",
30
+ text: "#c9d1d9", textMuted: "#636e7b", textEmphasized: "#f0f6fc",
31
+ border: "#30363d", borderDim: "#2d333b", borderFocus: "#8be9fd",
32
32
  primary: "#ff79c6", primaryDim: "#cc5f9e", secondary: "#8be9fd", accent: "#bd93f9",
33
33
  success: "#50fa7b", error: "#ff5555", warning: "#f1fa8c", info: "#8be9fd",
34
34
  },
@@ -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
  },