horizon-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/assets/python/highlights.scm +137 -0
  2. package/assets/python/tree-sitter-python.wasm +0 -0
  3. package/bin/horizon.js +2 -0
  4. package/package.json +40 -0
  5. package/src/ai/client.ts +369 -0
  6. package/src/ai/system-prompt.ts +86 -0
  7. package/src/app.ts +1454 -0
  8. package/src/chat/messages.ts +48 -0
  9. package/src/chat/renderer.ts +243 -0
  10. package/src/chat/types.ts +18 -0
  11. package/src/components/code-panel.ts +329 -0
  12. package/src/components/footer.ts +72 -0
  13. package/src/components/hooks-panel.ts +224 -0
  14. package/src/components/input-bar.ts +193 -0
  15. package/src/components/mode-bar.ts +245 -0
  16. package/src/components/session-panel.ts +294 -0
  17. package/src/components/settings-panel.ts +372 -0
  18. package/src/components/splash.ts +156 -0
  19. package/src/components/strategy-panel.ts +489 -0
  20. package/src/components/tab-bar.ts +112 -0
  21. package/src/components/tutorial-panel.ts +680 -0
  22. package/src/components/widgets/progress-bar.ts +38 -0
  23. package/src/components/widgets/sparkline.ts +57 -0
  24. package/src/hooks/executor.ts +109 -0
  25. package/src/index.ts +22 -0
  26. package/src/keys/handler.ts +198 -0
  27. package/src/platform/auth.ts +36 -0
  28. package/src/platform/client.ts +159 -0
  29. package/src/platform/config.ts +121 -0
  30. package/src/platform/session-sync.ts +158 -0
  31. package/src/platform/supabase.ts +376 -0
  32. package/src/platform/sync.ts +149 -0
  33. package/src/platform/tiers.ts +103 -0
  34. package/src/platform/tools.ts +163 -0
  35. package/src/platform/types.ts +86 -0
  36. package/src/platform/usage.ts +224 -0
  37. package/src/research/apis.ts +367 -0
  38. package/src/research/tools.ts +205 -0
  39. package/src/research/widgets.ts +523 -0
  40. package/src/state/store.ts +256 -0
  41. package/src/state/types.ts +109 -0
  42. package/src/strategy/ascii-chart.ts +74 -0
  43. package/src/strategy/code-stream.ts +146 -0
  44. package/src/strategy/dashboard.ts +140 -0
  45. package/src/strategy/persistence.ts +82 -0
  46. package/src/strategy/prompts.ts +626 -0
  47. package/src/strategy/sandbox.ts +137 -0
  48. package/src/strategy/tools.ts +764 -0
  49. package/src/strategy/validator.ts +216 -0
  50. package/src/strategy/widgets.ts +270 -0
  51. package/src/syntax/setup.ts +54 -0
  52. package/src/theme/colors.ts +107 -0
  53. package/src/theme/icons.ts +27 -0
  54. package/src/util/hyperlink.ts +21 -0
@@ -0,0 +1,764 @@
1
+ // Strategy mode AI tools — full coding partner
2
+ // The LLM can: write strategy code, run it, backtest it, read logs, build dashboards, deploy it
3
+
4
+ import { tool } from "ai";
5
+ import { z } from "zod";
6
+ import { validateStrategyCode, autoFixStrategyCode } from "./validator.ts";
7
+ import { gammaEvents } from "../research/apis.ts";
8
+ import { platform } from "../platform/client.ts";
9
+ import { store } from "../state/store.ts";
10
+ import { dashboard } from "./dashboard.ts";
11
+ import { runInSandbox, spawnInSandbox } from "./sandbox.ts";
12
+ import { saveStrategy, loadStrategy, listSavedStrategies } from "./persistence.ts";
13
+ import { hyperlink } from "../util/hyperlink.ts";
14
+ import type { StrategyDraft } from "../state/types.ts";
15
+
16
+ const t = tool as any;
17
+
18
+ const docsCache = new Map<string, string>();
19
+
20
+ // ── Process management ──
21
+
22
+ interface ManagedProcess {
23
+ proc: ReturnType<typeof Bun.spawn>;
24
+ stdout: string[];
25
+ stderr: string[];
26
+ startedAt: number;
27
+ cleanup?: () => Promise<void>;
28
+ }
29
+
30
+ export const runningProcesses = new Map<number, ManagedProcess>();
31
+ const customServers: ReturnType<typeof Bun.serve>[] = [];
32
+
33
+ function startCapturing(pid: number, managed: ManagedProcess): void {
34
+ // Stream stdout/stderr into rolling buffers
35
+ const readStream = async (stream: ReadableStream<Uint8Array>, buffer: string[]) => {
36
+ try {
37
+ const reader = stream.getReader();
38
+ const decoder = new TextDecoder();
39
+ while (true) {
40
+ const { done, value } = await reader.read();
41
+ if (done) break;
42
+ const text = decoder.decode(value);
43
+ for (const line of text.split("\n")) {
44
+ if (line) buffer.push(line);
45
+ }
46
+ // Rolling buffer — keep last 200 lines
47
+ while (buffer.length > 200) buffer.shift();
48
+ }
49
+ } catch {}
50
+ };
51
+ readStream(managed.proc.stdout as ReadableStream<Uint8Array>, managed.stdout);
52
+ readStream(managed.proc.stderr as ReadableStream<Uint8Array>, managed.stderr);
53
+
54
+ // Clean up when process exits
55
+ managed.proc.exited.then(() => {
56
+ // Keep in map for log reading — cleaned up on next run or quit
57
+ });
58
+ }
59
+
60
+ /** Clean up all running processes and servers */
61
+ export function cleanupStrategyProcesses(): void {
62
+ for (const [pid, managed] of runningProcesses) {
63
+ managed.cleanup?.();
64
+ runningProcesses.delete(pid);
65
+ }
66
+ for (const server of customServers) {
67
+ try { server.stop(); } catch {}
68
+ }
69
+ customServers.length = 0;
70
+ }
71
+
72
+ // ── Helper: extract hz.run() kwargs from strategy code ──
73
+
74
+ function extractRunKwargs(code: string) {
75
+ // Use multiline-aware extraction
76
+ const pipelineMatch = code.match(/pipeline\s*=\s*\[([\s\S]*?)\]/);
77
+ const pipelineFns = pipelineMatch
78
+ ? pipelineMatch[1]!.split(",").map((s) => s.trim()).filter((s) => s && !s.startsWith("#"))
79
+ : [];
80
+
81
+ const riskMatch = code.match(/hz\.Risk\(([\s\S]*?)\)/);
82
+ const riskArgs = riskMatch ? riskMatch[1]!.replace(/\n/g, " ").trim() : "max_position=100, max_drawdown_pct=10";
83
+
84
+ // Match params={...} — handle nested braces by counting depth
85
+ let paramsStr = "{}";
86
+ const paramsStart = code.indexOf("params={");
87
+ if (paramsStart !== -1) {
88
+ let depth = 0;
89
+ let end = paramsStart + 7;
90
+ for (let i = paramsStart + 7; i < code.length; i++) {
91
+ if (code[i] === "{") depth++;
92
+ if (code[i] === "}") {
93
+ if (depth === 0) { end = i + 1; break; }
94
+ depth--;
95
+ }
96
+ }
97
+ paramsStr = code.slice(paramsStart + 7, end);
98
+ }
99
+
100
+ // Match all market slugs
101
+ const marketsMatch = code.match(/markets\s*=\s*\[([\s\S]*?)\]/);
102
+ let marketNames: string[] = ["test-market"];
103
+ if (marketsMatch) {
104
+ const raw = marketsMatch[1]!;
105
+ const slugs = [...raw.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]!);
106
+ if (slugs.length > 0) marketNames = slugs;
107
+ }
108
+
109
+ return { pipelineFns, riskArgs, paramsStr, marketNames };
110
+ }
111
+
112
+ // ── Safe environment for run_command (no secrets) ──
113
+
114
+ function commandEnv(): Record<string, string> {
115
+ const env: Record<string, string> = {};
116
+ const allow = ["PATH", "HOME", "LANG", "LC_ALL", "TERM", "PYTHONPATH", "VIRTUAL_ENV", "CONDA_PREFIX", "SHELL", "USER", "LOGNAME"];
117
+ for (const key of allow) {
118
+ if (process.env[key]) env[key] = process.env[key]!;
119
+ }
120
+ return env;
121
+ }
122
+
123
+ // ── Tools ──
124
+ // Code generation uses text streaming (```python fences) detected by app.ts.
125
+ // These tools handle editing, validation, execution, and deployment.
126
+
127
+ export const strategyTools: Record<string, any> = {
128
+
129
+ edit_strategy: t({
130
+ description: "Make a targeted edit to the current strategy code using find-and-replace. Use for tweaking parameters, fixing bugs, adding a few lines. The find string must match exactly.",
131
+ parameters: z.object({
132
+ find: z.string().describe("Exact string to find in the current code"),
133
+ replace: z.string().describe("String to replace it with"),
134
+ change_summary: z.string().describe("What changed (1 sentence)"),
135
+ }),
136
+ execute: async (args: any) => {
137
+ const draft = store.getActiveSession()?.strategyDraft;
138
+ if (!draft) return { error: "No strategy loaded. Write a strategy in a ```python code fence first." };
139
+
140
+ if (!draft.code.includes(args.find)) {
141
+ return { error: `Could not find the text to replace. Make sure 'find' matches exactly.`, find: args.find };
142
+ }
143
+
144
+ const newCode = draft.code.replace(args.find, args.replace);
145
+ const fixedCode = autoFixStrategyCode(newCode);
146
+ const errors = validateStrategyCode(fixedCode);
147
+
148
+ store.updateStrategyDraft({
149
+ code: fixedCode,
150
+ validationStatus: errors.length === 0 ? "valid" : "invalid",
151
+ validationErrors: errors,
152
+ phase: "iterated",
153
+ });
154
+
155
+ // Auto-save
156
+ await saveStrategy(draft.name, fixedCode).catch(() => {});
157
+
158
+ return {
159
+ strategy_name: draft.name,
160
+ code: fixedCode,
161
+ change_summary: args.change_summary,
162
+ validation: { valid: errors.length === 0, errors },
163
+ lines_changed: args.replace.split("\n").length,
164
+ };
165
+ },
166
+ }),
167
+
168
+ validate_strategy: t({
169
+ description: "Re-validate the current strategy code. Validation runs automatically — only call if the user explicitly asks to validate.",
170
+ parameters: z.object({
171
+ code: z.string().optional().describe("Code to validate. Omit to validate current draft."),
172
+ }),
173
+ execute: async (args: any) => {
174
+ const code = args.code ?? store.getActiveSession()?.strategyDraft?.code;
175
+ if (!code) return { valid: false, errors: [{ line: null, message: "No strategy code to validate" }] };
176
+
177
+ const errors = validateStrategyCode(code);
178
+
179
+ if (!args.code && store.getActiveSession()?.strategyDraft) {
180
+ store.updateStrategyDraft({
181
+ validationStatus: errors.length === 0 ? "valid" : "invalid",
182
+ validationErrors: errors,
183
+ });
184
+ }
185
+ return { valid: errors.length === 0, errors };
186
+ },
187
+ }),
188
+
189
+ lookup_sdk_docs: t({
190
+ description: "Look up advanced Horizon SDK docs (arbitrage, copy-trading, wallet-intelligence, stealth, sentinel, oracle). Do NOT call for basic SDK usage — types, feeds, pipeline, risk, backtesting are in the system prompt.",
191
+ parameters: z.object({
192
+ topic: z.string().describe("SDK topic: types, feeds, context, pipeline, hz.run, risk, backtesting, plotting, ascii-plotting, exchanges, arbitrage, signals, etc."),
193
+ }),
194
+ execute: async (args: any) => {
195
+ const topic = args.topic.toLowerCase().replace(/\s+/g, "-");
196
+ const cached = docsCache.get(topic);
197
+ if (cached) return { topic: args.topic, content: cached };
198
+
199
+ // Fetch the full markdown docs index (llms-full.txt) and extract the relevant section
200
+ try {
201
+ if (!docsCache.has("__full__")) {
202
+ const res = await fetch("https://mathematicalcompany.mintlify.app/llms-full.txt", {
203
+ signal: AbortSignal.timeout(8000),
204
+ });
205
+ if (res.ok) {
206
+ docsCache.set("__full__", await res.text());
207
+ }
208
+ }
209
+
210
+ const fullDocs = docsCache.get("__full__");
211
+ if (fullDocs) {
212
+ // Find the section matching the topic — look for markdown headers
213
+ const topicVariants = [topic, topic.replace(/-/g, " "), args.topic];
214
+ let bestMatch = "";
215
+ for (const variant of topicVariants) {
216
+ // Find a heading containing the topic
217
+ const headingPattern = new RegExp(`^#+\\s+.*${variant.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`, "im");
218
+ const headingMatch = fullDocs.match(headingPattern);
219
+ if (headingMatch?.index !== undefined) {
220
+ // Extract from this heading to the next same-level heading (or 4000 chars)
221
+ const level = headingMatch[0].match(/^#+/)![0].length;
222
+ const rest = fullDocs.slice(headingMatch.index);
223
+ const nextHeading = rest.slice(1).search(new RegExp(`^#{1,${level}}\\s`, "m"));
224
+ const section = nextHeading !== -1 ? rest.slice(0, nextHeading + 1) : rest;
225
+ bestMatch = section.slice(0, 4000);
226
+ break;
227
+ }
228
+ }
229
+
230
+ if (bestMatch) {
231
+ docsCache.set(topic, bestMatch);
232
+ return { topic: args.topic, content: bestMatch };
233
+ }
234
+
235
+ // Fallback: keyword search in the full doc
236
+ const lines = fullDocs.split("\n");
237
+ const relevant: string[] = [];
238
+ const keywords = topic.split("-").filter((w: string) => w.length > 2);
239
+ for (let i = 0; i < lines.length && relevant.length < 80; i++) {
240
+ const line = lines[i]!.toLowerCase();
241
+ if (keywords.some((kw: string) => line.includes(kw))) {
242
+ // Include surrounding context
243
+ const start = Math.max(0, i - 2);
244
+ const end = Math.min(lines.length, i + 5);
245
+ for (let j = start; j < end; j++) {
246
+ if (!relevant.includes(lines[j]!)) relevant.push(lines[j]!);
247
+ }
248
+ }
249
+ }
250
+ if (relevant.length > 0) {
251
+ const content = relevant.join("\n").slice(0, 4000);
252
+ docsCache.set(topic, content);
253
+ return { topic: args.topic, content };
254
+ }
255
+ }
256
+ } catch {}
257
+
258
+ return { topic: args.topic, content: `SDK docs for "${args.topic}" not found. Use the embedded reference in your system prompt.` };
259
+ },
260
+ }),
261
+
262
+ polymarket_data: t({
263
+ description: "Search Polymarket for real market slugs, spreads, volume. Call only when you need a real market slug or the user asks about specific markets.",
264
+ parameters: z.object({
265
+ query: z.string().describe("Search query (e.g. 'bitcoin', 'election')"),
266
+ limit: z.number().optional().describe("Max results (default 5)"),
267
+ }),
268
+ execute: async (args: any) => {
269
+ const events = await gammaEvents({ query: args.query, limit: args.limit ?? 5 });
270
+ return {
271
+ query: args.query,
272
+ markets: events.map((e: any) => ({
273
+ title: e.title, slug: e.slug,
274
+ yesPrice: e.markets?.[0]?.yesPrice,
275
+ noPrice: e.markets?.[0]?.noPrice,
276
+ volume24hr: e.volume24hr, liquidity: e.liquidity,
277
+ spread: e.markets?.[0]?.bestAsk && e.markets?.[0]?.bestBid
278
+ ? +(e.markets[0].bestAsk - e.markets[0].bestBid).toFixed(4) : null,
279
+ })),
280
+ };
281
+ },
282
+ }),
283
+
284
+ // ── Backtest (real hz.backtest()) ──
285
+
286
+ backtest_strategy: t({
287
+ description: "Run hz.backtest() on the current strategy using the Horizon SDK locally. Returns real metrics, equity curve, and ASCII dashboard.",
288
+ parameters: z.object({
289
+ data_points: z.number().optional().describe("Synthetic price ticks (default 500)"),
290
+ initial_capital: z.number().optional().describe("Starting capital USD (default 1000)"),
291
+ base_price: z.number().optional().describe("Base price (default 0.50)"),
292
+ fill_model: z.enum(["deterministic", "probabilistic", "glft"]).optional().describe("Fill model (default deterministic)"),
293
+ }),
294
+ execute: async (args: any) => {
295
+ const draft = store.getActiveSession()?.strategyDraft;
296
+ if (!draft) return { error: "No strategy loaded yet." };
297
+ if (draft.validationStatus === "invalid") {
298
+ return { error: "Strategy has validation errors. Fix them first.", errors: draft.validationErrors };
299
+ }
300
+
301
+ const dataPoints = args.data_points ?? 500;
302
+ const capital = args.initial_capital ?? 1000;
303
+ const basePrice = args.base_price ?? 0.50;
304
+ const fillModel = args.fill_model ?? "deterministic";
305
+
306
+ const code = draft.code;
307
+ const hzRunIdx = code.search(/hz\.run\s*\(/);
308
+ const pipelineCode = hzRunIdx !== -1 ? code.slice(0, hzRunIdx).trimEnd() : code;
309
+ const { pipelineFns, riskArgs, paramsStr, marketNames } = extractRunKwargs(code);
310
+
311
+ if (pipelineFns.length === 0) {
312
+ return { error: "Could not extract pipeline functions. Ensure pipeline=[...] is in hz.run()." };
313
+ }
314
+
315
+ // Write backtest config to a JSON file (avoids template injection)
316
+ const backtestConfig = {
317
+ name: draft.name.replace(/[^a-zA-Z0-9_-]/g, "_"),
318
+ markets: marketNames,
319
+ data_points: dataPoints,
320
+ base_price: basePrice,
321
+ initial_capital: capital,
322
+ fill_model: fillModel,
323
+ risk_args: riskArgs,
324
+ params_str: paramsStr,
325
+ pipeline_fns: pipelineFns,
326
+ };
327
+
328
+ const script = `
329
+ import horizon as hz
330
+ from horizon.context import FeedData
331
+ import json, sys
332
+
333
+ ${pipelineCode}
334
+
335
+ # Load config from JSON file (no string interpolation)
336
+ with open("backtest_config.json") as f:
337
+ _cfg = json.load(f)
338
+
339
+ data = []
340
+ price = _cfg["base_price"]
341
+ for i in range(_cfg["data_points"]):
342
+ noise = (((i * 7 + 13) % 100) / 100 - 0.5) * 0.05
343
+ revert = (_cfg["base_price"] - price) * 0.05
344
+ price = max(0.01, min(0.99, price + noise + revert))
345
+ data.append({"timestamp": float(i), "price": round(price, 4)})
346
+
347
+ try:
348
+ result = hz.backtest(
349
+ name=_cfg["name"],
350
+ markets=_cfg["markets"],
351
+ data=data,
352
+ pipeline=[${pipelineFns.join(", ")}],
353
+ risk=hz.Risk(${riskArgs}),
354
+ params=${paramsStr},
355
+ initial_capital=_cfg["initial_capital"],
356
+ fill_model=_cfg["fill_model"],
357
+ )
358
+ m = result.metrics
359
+ out = {
360
+ "strategy_name": _cfg["name"],
361
+ "equity_curve": [round(e, 2) for _, e in result.equity_curve],
362
+ "trade_count": len(result.trades),
363
+ "metrics": {
364
+ "total_return": round(getattr(m, 'total_return', 0), 4),
365
+ "max_drawdown": round(getattr(m, 'max_drawdown', 0), 4),
366
+ "sharpe_ratio": round(getattr(m, 'sharpe_ratio', 0), 2),
367
+ "sortino_ratio": round(getattr(m, 'sortino_ratio', 0), 2),
368
+ "win_rate": round(getattr(m, 'win_rate', 0), 4),
369
+ "profit_factor": round(getattr(m, 'profit_factor', 0), 2),
370
+ "total_trades": getattr(m, 'trade_count', 0),
371
+ "expectancy": round(getattr(m, 'expectancy', 0), 4),
372
+ "total_fees": round(getattr(m, 'total_fees', 0), 4),
373
+ },
374
+ "pnl_by_market": result.pnl_by_market(),
375
+ }
376
+ try:
377
+ out["summary"] = result.summary()
378
+ except:
379
+ pass
380
+ print("---BACKTEST_JSON---")
381
+ print(json.dumps(out))
382
+ print("---END_BACKTEST_JSON---")
383
+ try:
384
+ bundle = hz.from_backtest(result)
385
+ print("---ASCII_DASHBOARD---")
386
+ print(hz.dashboard(bundle))
387
+ print("---END_ASCII_DASHBOARD---")
388
+ except Exception as de:
389
+ print(f"---ASCII_DASHBOARD---\\nDashboard error: {de}\\n---END_ASCII_DASHBOARD---")
390
+ except Exception as e:
391
+ import traceback
392
+ print("---BACKTEST_ERROR---")
393
+ print(str(e))
394
+ traceback.print_exc()
395
+ print("---END_BACKTEST_ERROR---")
396
+ sys.exit(1)
397
+ `;
398
+
399
+ try {
400
+ // Write config to a temp file that the Python script reads (avoids template injection)
401
+ const { mkdtemp } = await import("fs/promises");
402
+ const { join } = await import("path");
403
+ const { tmpdir } = await import("os");
404
+ const sandboxDir = await mkdtemp(join(tmpdir(), "horizon-backtest-"));
405
+ await Bun.write(join(sandboxDir, "backtest_config.json"), JSON.stringify(backtestConfig));
406
+
407
+ const { stdout, stderr, exitCode, timedOut } = await runInSandbox({ code: script, timeout: 60000, cwd: sandboxDir });
408
+
409
+ if (timedOut) return { error: "Backtest timed out (60s limit)" };
410
+
411
+ // Check for early crash (import failure, syntax error, etc.)
412
+ if (exitCode !== 0 && !stdout.includes("---BACKTEST_ERROR---") && !stdout.includes("---BACKTEST_JSON---")) {
413
+ const errLines = (stderr || stdout).trim().split("\n").slice(-10).join("\n");
414
+ return {
415
+ error: `Python exited with code ${exitCode}`,
416
+ detail: errLines || "No output",
417
+ hint: stderr.includes("ModuleNotFoundError") ? "Horizon SDK not found. Run: pip install horizon" : undefined,
418
+ };
419
+ }
420
+
421
+ if (stdout.includes("---BACKTEST_ERROR---")) {
422
+ const errMsg = stdout.split("---BACKTEST_ERROR---")[1]?.split("---END_BACKTEST_ERROR---")[0]?.trim();
423
+ return { error: `Backtest failed: ${errMsg}` };
424
+ }
425
+
426
+ const jsonMatch = stdout.match(/---BACKTEST_JSON---([\s\S]*?)---END_BACKTEST_JSON---/);
427
+ const dashMatch = stdout.match(/---ASCII_DASHBOARD---([\s\S]*?)---END_ASCII_DASHBOARD---/);
428
+
429
+ if (!jsonMatch) {
430
+ return {
431
+ error: "Could not parse backtest output",
432
+ detail: stderr ? stderr.slice(0, 800) : stdout.slice(0, 800),
433
+ hint: "The Python process ran but produced no structured output",
434
+ };
435
+ }
436
+
437
+ const result = JSON.parse(jsonMatch[1]!.trim());
438
+ return { ...result, ascii_dashboard: dashMatch ? dashMatch[1]!.trim() : null, duration: `${dataPoints} ticks` };
439
+ } catch (err) {
440
+ return { error: `Backtest execution failed: ${err instanceof Error ? err.message : String(err)}`, hint: "Ensure Horizon SDK is installed: pip install horizon" };
441
+ }
442
+ },
443
+ }),
444
+
445
+ // ── Local Execution & Process Management ──
446
+
447
+ run_strategy: t({
448
+ description: "Run the current strategy code locally as a background Python process. Returns PID. Use read_logs(pid) to monitor. Must use mode='paper'. Process shows in the status bar as 'N running'.",
449
+ parameters: z.object({
450
+ timeout_secs: z.number().optional().describe("Max runtime seconds (default 3600 = 1 hour)"),
451
+ }),
452
+ execute: async (args: any) => {
453
+ const draft = store.getActiveSession()?.strategyDraft;
454
+ if (!draft) return { error: "No strategy loaded yet." };
455
+ if (draft.validationStatus !== "valid") {
456
+ return { error: "Strategy must pass validation first." };
457
+ }
458
+
459
+ // Safety: require paper mode
460
+ if (!/mode\s*=\s*["']paper["']/.test(draft.code)) {
461
+ return { error: 'Safety: strategy must use mode="paper" for local execution.' };
462
+ }
463
+
464
+ const timeout = args.timeout_secs ?? 3600;
465
+ try {
466
+ const { proc, cleanup } = spawnInSandbox(draft.code);
467
+
468
+ const pid = proc.pid;
469
+ const managed: ManagedProcess = { proc, stdout: [], stderr: [], startedAt: Date.now(), cleanup };
470
+ runningProcesses.set(pid, managed);
471
+ startCapturing(pid, managed);
472
+
473
+ // Auto-kill after timeout
474
+ setTimeout(() => {
475
+ const m = runningProcesses.get(pid);
476
+ if (m) { m.cleanup?.(); runningProcesses.delete(pid); }
477
+ }, timeout * 1000);
478
+
479
+ // Wait a beat to check for immediate crash
480
+ await new Promise((r) => setTimeout(r, 500));
481
+ const exitCode = proc.exitCode;
482
+ if (exitCode !== null && exitCode !== 0) {
483
+ const err = managed.stderr.join("\n");
484
+ runningProcesses.delete(pid);
485
+ return { error: `Process crashed immediately (exit ${exitCode})`, stderr: err.slice(0, 1000) };
486
+ }
487
+
488
+ return {
489
+ success: true, pid, status: "running", timeout_secs: timeout,
490
+ initial_output: managed.stdout.slice(0, 5).join("\n"),
491
+ message: `Strategy running (PID ${pid}). Use read_logs(${pid}) to see output. Auto-stops after ${Math.round(timeout / 60)}min.`,
492
+ };
493
+ } catch (err) {
494
+ return { error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
495
+ }
496
+ },
497
+ }),
498
+
499
+ read_logs: t({
500
+ description: "Read stdout/stderr from a running or recently-exited background process. Use after run_strategy to monitor what the strategy is doing.",
501
+ parameters: z.object({
502
+ pid: z.number().describe("Process ID"),
503
+ lines: z.number().optional().describe("Number of recent lines (default 50)"),
504
+ stream: z.enum(["stdout", "stderr", "both"]).optional().describe("Which stream (default both)"),
505
+ }),
506
+ execute: async (args: any) => {
507
+ const managed = runningProcesses.get(args.pid);
508
+ if (!managed) return { error: `No process with PID ${args.pid}` };
509
+
510
+ const n = args.lines ?? 50;
511
+ const stream = args.stream ?? "both";
512
+ const alive = managed.proc.exitCode === null;
513
+
514
+ const result: any = {
515
+ pid: args.pid,
516
+ alive,
517
+ uptime_secs: Math.round((Date.now() - managed.startedAt) / 1000),
518
+ };
519
+
520
+ if (stream === "stdout" || stream === "both") {
521
+ result.stdout = managed.stdout.slice(-n).join("\n");
522
+ }
523
+ if (stream === "stderr" || stream === "both") {
524
+ result.stderr = managed.stderr.slice(-n).join("\n");
525
+ }
526
+ if (!alive) {
527
+ result.exit_code = managed.proc.exitCode;
528
+ }
529
+ return result;
530
+ },
531
+ }),
532
+
533
+ stop_process: t({
534
+ description: "Stop a running background process by PID.",
535
+ parameters: z.object({
536
+ pid: z.number().describe("Process ID to stop"),
537
+ }),
538
+ execute: async (args: any) => {
539
+ const managed = runningProcesses.get(args.pid);
540
+ if (!managed) return { error: `No process with PID ${args.pid}` };
541
+ const lastLogs = managed.stdout.slice(-10).join("\n");
542
+ managed.cleanup?.();
543
+ runningProcesses.delete(args.pid);
544
+ return { success: true, pid: args.pid, last_output: lastLogs };
545
+ },
546
+ }),
547
+
548
+ run_command: t({
549
+ description: "Execute a whitelisted shell command. For: pip install, file inspection, python scripts, git, opening URLs. Commands not in the whitelist are blocked.",
550
+ parameters: z.object({
551
+ command: z.string().describe("Shell command"),
552
+ timeout_secs: z.number().optional().describe("Max seconds (default 30)"),
553
+ }),
554
+ execute: async (args: any) => {
555
+ const cmd = args.command.trim().split(/\s+/)[0] ?? "";
556
+ const ALLOWED = new Set([
557
+ "pip", "pip3", "python", "python3", "node", "bun", "npm", "npx",
558
+ "cat", "head", "tail", "less", "wc", "ls", "find", "grep", "rg",
559
+ "pwd", "which", "git", "curl", "open", "xdg-open",
560
+ "mkdir", "touch", "cp", "mv",
561
+ ]);
562
+ // Strict whitelist — no bypass. "env", "echo", "chmod", "rm" removed to prevent secret leaking/destruction.
563
+ if (!ALLOWED.has(cmd)) {
564
+ return { error: `Command "${cmd}" is not allowed.` };
565
+ }
566
+
567
+ // Block shell tricks that could leak env or escape
568
+ const dangerous = /\$\(|`|\benv\b|\bexport\b|\bsource\b|\beval\b|\bexec\b|>\s*\/|\/etc\/|\/proc\/|~\/\.horizon/i;
569
+ if (dangerous.test(args.command)) {
570
+ return { error: "Command contains blocked patterns." };
571
+ }
572
+
573
+ const timeout = (args.timeout_secs ?? 30) * 1000;
574
+ try {
575
+ const proc = Bun.spawn(["bash", "-c", args.command], {
576
+ stdout: "pipe", stderr: "pipe",
577
+ env: commandEnv(), // restricted env — no secrets
578
+ });
579
+ const result = await Promise.race([
580
+ (async () => {
581
+ const stdout = await new Response(proc.stdout).text();
582
+ const stderr = await new Response(proc.stderr).text();
583
+ await proc.exited;
584
+ return { stdout, stderr, exitCode: proc.exitCode };
585
+ })(),
586
+ new Promise<never>((_, reject) =>
587
+ setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, timeout)
588
+ ),
589
+ ]);
590
+ return { stdout: result.stdout.slice(0, 3000), stderr: result.stderr.slice(0, 1000), exit_code: result.exitCode };
591
+ } catch (err) {
592
+ return { error: err instanceof Error ? err.message : String(err) };
593
+ }
594
+ },
595
+ }),
596
+
597
+ // ── Save & Deploy ──
598
+
599
+ save_strategy: t({
600
+ description: "Save current strategy draft to the Horizon platform. Returns strategy_id needed for deploy_strategy. Code must pass validation first.",
601
+ parameters: z.object({ name: z.string().optional().describe("Override strategy name") }),
602
+ execute: async (args: any) => {
603
+ const draft = store.getActiveSession()?.strategyDraft;
604
+ if (!draft) return { error: "No strategy loaded." };
605
+ if (!draft.code) return { error: "Strategy has no code." };
606
+ if (draft.validationStatus === "invalid") {
607
+ return { error: "Strategy has validation errors. Fix them first.", errors: draft.validationErrors };
608
+ }
609
+ try {
610
+ const data = await platform.createStrategy({
611
+ name: args.name ?? draft.name, code: draft.code,
612
+ params: draft.params, risk_config: draft.riskConfig ?? undefined,
613
+ });
614
+ store.updateStrategyDraft({ strategyId: data.id, phase: "saved" });
615
+ return {
616
+ success: true,
617
+ strategy_id: data.id,
618
+ name: draft.name,
619
+ hint: "Now call list_credentials() to get a credential_id, then deploy_strategy().",
620
+ };
621
+ } catch (err) {
622
+ return { error: `Failed to save: ${err instanceof Error ? err.message : String(err)}` };
623
+ }
624
+ },
625
+ }),
626
+
627
+ // ── Local file persistence ──
628
+
629
+ load_saved_strategy: t({
630
+ description: "Load a previously saved strategy from disk. Only call when the user explicitly asks to load or resume a saved strategy.",
631
+ parameters: z.object({
632
+ name: z.string().describe("Strategy name (filename without .py)"),
633
+ }),
634
+ execute: async (args: any) => {
635
+ const result = await loadStrategy(args.name);
636
+ if (!result) return { error: `Strategy "${args.name}" not found in ~/.horizon/strategies/` };
637
+
638
+ const fixedCode = autoFixStrategyCode(result.code);
639
+ const errors = validateStrategyCode(fixedCode);
640
+
641
+ const draft: StrategyDraft = {
642
+ name: args.name,
643
+ code: fixedCode,
644
+ params: {},
645
+ explanation: "",
646
+ riskConfig: null,
647
+ validationStatus: errors.length === 0 ? "valid" : "invalid",
648
+ validationErrors: errors,
649
+ phase: "generated",
650
+ };
651
+ store.setStrategyDraft(draft);
652
+
653
+ return {
654
+ strategy_name: args.name,
655
+ code: fixedCode,
656
+ path: result.path,
657
+ validation: { valid: errors.length === 0, errors },
658
+ };
659
+ },
660
+ }),
661
+
662
+ list_saved_strategies: t({
663
+ description: "List saved strategies on disk. Only call when the user asks to see their saved strategies. Do NOT call this before generating a new strategy.",
664
+ parameters: z.object({}),
665
+ execute: async () => {
666
+ const strategies = await listSavedStrategies();
667
+ return {
668
+ count: strategies.length,
669
+ strategies: strategies.map((s) => ({
670
+ name: s.name,
671
+ path: s.path,
672
+ modified: s.modified ? new Date(s.modified).toISOString() : null,
673
+ })),
674
+ };
675
+ },
676
+ }),
677
+
678
+ // ── Dashboard ──
679
+
680
+ spawn_dashboard: t({
681
+ description: `Serve a local web dashboard. Two modes:
682
+
683
+ 1. **Built-in monitor** — pass strategy_id. Auto-connects to live platform metrics.
684
+ 2. **Custom HTML** — pass custom_html you write from scratch.
685
+
686
+ Custom dashboards get a FREE live API:
687
+ - GET /api/metrics → platform metrics (if strategy_id provided)
688
+ - GET /api/logs → platform deployment logs
689
+ - GET /api/strategy → current strategy draft (code, name, params, risk)
690
+ - GET /api/local-logs → stdout/stderr from local run_strategy processes
691
+
692
+ Your HTML can fetch("/api/metrics").then(r => r.json()) to get live data. Auto-refresh with setInterval.`,
693
+ parameters: z.object({
694
+ strategy_id: z.string().optional().describe("Strategy UUID — enables /api/metrics and /api/logs from the platform"),
695
+ custom_html: z.string().optional().describe("Complete HTML page you write from scratch (dark theme, Chart.js, etc.)"),
696
+ port: z.number().optional().describe("Port (default: random)"),
697
+ }),
698
+ execute: async (args: any) => {
699
+ try {
700
+ if (args.strategy_id && !args.custom_html) {
701
+ const url = dashboard.start(args.strategy_id, args.port);
702
+ return { success: true, url, message: `Monitor at ${hyperlink(url)}` };
703
+ }
704
+
705
+ // Custom HTML with live API backend
706
+ const html = args.custom_html ?? "";
707
+ const strategyId = args.strategy_id;
708
+
709
+ const server = Bun.serve({
710
+ port: args.port || 0,
711
+ hostname: "127.0.0.1", // localhost only — not accessible from network
712
+ fetch: async (req) => {
713
+ const url = new URL(req.url);
714
+
715
+ // Live API: platform metrics
716
+ if (url.pathname === "/api/metrics" && strategyId) {
717
+ try { return Response.json(await platform.getMetrics(strategyId, 20)); }
718
+ catch (e: any) { return Response.json({ error: e.message }, { status: 500 }); }
719
+ }
720
+
721
+ // Live API: platform logs
722
+ if (url.pathname === "/api/logs" && strategyId) {
723
+ try { return Response.json(await platform.getLogs(strategyId, 50)); }
724
+ catch (e: any) { return Response.json({ error: e.message }, { status: 500 }); }
725
+ }
726
+
727
+ // Live API: current strategy draft
728
+ if (url.pathname === "/api/strategy") {
729
+ const draft = store.getActiveSession()?.strategyDraft;
730
+ return Response.json(draft ? { name: draft.name, code: draft.code, params: draft.params, riskConfig: draft.riskConfig, validationStatus: draft.validationStatus } : { error: "No strategy loaded" });
731
+ }
732
+
733
+ // Live API: local process logs
734
+ if (url.pathname === "/api/local-logs") {
735
+ const allLogs: Record<string, { stdout: string[]; stderr: string[]; alive: boolean }> = {};
736
+ for (const [pid, managed] of runningProcesses) {
737
+ allLogs[pid] = { stdout: managed.stdout.slice(-50), stderr: managed.stderr.slice(-20), alive: managed.proc.exitCode === null };
738
+ }
739
+ return Response.json(allLogs);
740
+ }
741
+
742
+ // CORS preflight
743
+ if (req.method === "OPTIONS") {
744
+ return new Response(null, { headers: { "Access-Control-Allow-Origin": "http://127.0.0.1", "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type" } });
745
+ }
746
+
747
+ // Serve the HTML
748
+ return new Response(html, { headers: { "Content-Type": "text/html", "Access-Control-Allow-Origin": "http://127.0.0.1" } });
749
+ },
750
+ });
751
+
752
+ const dashUrl = `http://localhost:${server.port}`;
753
+ customServers.push(server);
754
+
755
+ const apis = ["/api/strategy", "/api/local-logs"];
756
+ if (strategyId) apis.push("/api/metrics", "/api/logs");
757
+
758
+ return { success: true, url: dashUrl, available_apis: apis, message: `Dashboard at ${hyperlink(dashUrl)}` };
759
+ } catch (err) {
760
+ return { error: `Dashboard failed: ${err instanceof Error ? err.message : String(err)}` };
761
+ }
762
+ },
763
+ }),
764
+ };