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.
- package/assets/python/highlights.scm +137 -0
- package/assets/python/tree-sitter-python.wasm +0 -0
- package/bin/horizon.js +2 -0
- package/package.json +40 -0
- package/src/ai/client.ts +369 -0
- package/src/ai/system-prompt.ts +86 -0
- package/src/app.ts +1454 -0
- package/src/chat/messages.ts +48 -0
- package/src/chat/renderer.ts +243 -0
- package/src/chat/types.ts +18 -0
- package/src/components/code-panel.ts +329 -0
- package/src/components/footer.ts +72 -0
- package/src/components/hooks-panel.ts +224 -0
- package/src/components/input-bar.ts +193 -0
- package/src/components/mode-bar.ts +245 -0
- package/src/components/session-panel.ts +294 -0
- package/src/components/settings-panel.ts +372 -0
- package/src/components/splash.ts +156 -0
- package/src/components/strategy-panel.ts +489 -0
- package/src/components/tab-bar.ts +112 -0
- package/src/components/tutorial-panel.ts +680 -0
- package/src/components/widgets/progress-bar.ts +38 -0
- package/src/components/widgets/sparkline.ts +57 -0
- package/src/hooks/executor.ts +109 -0
- package/src/index.ts +22 -0
- package/src/keys/handler.ts +198 -0
- package/src/platform/auth.ts +36 -0
- package/src/platform/client.ts +159 -0
- package/src/platform/config.ts +121 -0
- package/src/platform/session-sync.ts +158 -0
- package/src/platform/supabase.ts +376 -0
- package/src/platform/sync.ts +149 -0
- package/src/platform/tiers.ts +103 -0
- package/src/platform/tools.ts +163 -0
- package/src/platform/types.ts +86 -0
- package/src/platform/usage.ts +224 -0
- package/src/research/apis.ts +367 -0
- package/src/research/tools.ts +205 -0
- package/src/research/widgets.ts +523 -0
- package/src/state/store.ts +256 -0
- package/src/state/types.ts +109 -0
- package/src/strategy/ascii-chart.ts +74 -0
- package/src/strategy/code-stream.ts +146 -0
- package/src/strategy/dashboard.ts +140 -0
- package/src/strategy/persistence.ts +82 -0
- package/src/strategy/prompts.ts +626 -0
- package/src/strategy/sandbox.ts +137 -0
- package/src/strategy/tools.ts +764 -0
- package/src/strategy/validator.ts +216 -0
- package/src/strategy/widgets.ts +270 -0
- package/src/syntax/setup.ts +54 -0
- package/src/theme/colors.ts +107 -0
- package/src/theme/icons.ts +27 -0
- package/src/util/hyperlink.ts +21 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
; Identifier naming conventions
|
|
2
|
+
|
|
3
|
+
(identifier) @variable
|
|
4
|
+
|
|
5
|
+
((identifier) @constructor
|
|
6
|
+
(#match? @constructor "^[A-Z]"))
|
|
7
|
+
|
|
8
|
+
((identifier) @constant
|
|
9
|
+
(#match? @constant "^[A-Z][A-Z_]*$"))
|
|
10
|
+
|
|
11
|
+
; Function calls
|
|
12
|
+
|
|
13
|
+
(decorator) @function
|
|
14
|
+
(decorator
|
|
15
|
+
(identifier) @function)
|
|
16
|
+
|
|
17
|
+
(call
|
|
18
|
+
function: (attribute attribute: (identifier) @function.method))
|
|
19
|
+
(call
|
|
20
|
+
function: (identifier) @function)
|
|
21
|
+
|
|
22
|
+
; Builtin functions
|
|
23
|
+
|
|
24
|
+
((call
|
|
25
|
+
function: (identifier) @function.builtin)
|
|
26
|
+
(#match?
|
|
27
|
+
@function.builtin
|
|
28
|
+
"^(abs|all|any|ascii|bin|bool|breakpoint|bytearray|bytes|callable|chr|classmethod|compile|complex|delattr|dict|dir|divmod|enumerate|eval|exec|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|isinstance|issubclass|iter|len|list|locals|map|max|memoryview|min|next|object|oct|open|ord|pow|print|property|range|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|vars|zip|__import__)$"))
|
|
29
|
+
|
|
30
|
+
; Function definitions
|
|
31
|
+
|
|
32
|
+
(function_definition
|
|
33
|
+
name: (identifier) @function)
|
|
34
|
+
|
|
35
|
+
(attribute attribute: (identifier) @property)
|
|
36
|
+
(type (identifier) @type)
|
|
37
|
+
|
|
38
|
+
; Literals
|
|
39
|
+
|
|
40
|
+
[
|
|
41
|
+
(none)
|
|
42
|
+
(true)
|
|
43
|
+
(false)
|
|
44
|
+
] @constant.builtin
|
|
45
|
+
|
|
46
|
+
[
|
|
47
|
+
(integer)
|
|
48
|
+
(float)
|
|
49
|
+
] @number
|
|
50
|
+
|
|
51
|
+
(comment) @comment
|
|
52
|
+
(string) @string
|
|
53
|
+
(escape_sequence) @escape
|
|
54
|
+
|
|
55
|
+
(interpolation
|
|
56
|
+
"{" @punctuation.special
|
|
57
|
+
"}" @punctuation.special) @embedded
|
|
58
|
+
|
|
59
|
+
[
|
|
60
|
+
"-"
|
|
61
|
+
"-="
|
|
62
|
+
"!="
|
|
63
|
+
"*"
|
|
64
|
+
"**"
|
|
65
|
+
"**="
|
|
66
|
+
"*="
|
|
67
|
+
"/"
|
|
68
|
+
"//"
|
|
69
|
+
"//="
|
|
70
|
+
"/="
|
|
71
|
+
"&"
|
|
72
|
+
"&="
|
|
73
|
+
"%"
|
|
74
|
+
"%="
|
|
75
|
+
"^"
|
|
76
|
+
"^="
|
|
77
|
+
"+"
|
|
78
|
+
"->"
|
|
79
|
+
"+="
|
|
80
|
+
"<"
|
|
81
|
+
"<<"
|
|
82
|
+
"<<="
|
|
83
|
+
"<="
|
|
84
|
+
"<>"
|
|
85
|
+
"="
|
|
86
|
+
":="
|
|
87
|
+
"=="
|
|
88
|
+
">"
|
|
89
|
+
">="
|
|
90
|
+
">>"
|
|
91
|
+
">>="
|
|
92
|
+
"|"
|
|
93
|
+
"|="
|
|
94
|
+
"~"
|
|
95
|
+
"@="
|
|
96
|
+
"and"
|
|
97
|
+
"in"
|
|
98
|
+
"is"
|
|
99
|
+
"not"
|
|
100
|
+
"or"
|
|
101
|
+
"is not"
|
|
102
|
+
"not in"
|
|
103
|
+
] @operator
|
|
104
|
+
|
|
105
|
+
[
|
|
106
|
+
"as"
|
|
107
|
+
"assert"
|
|
108
|
+
"async"
|
|
109
|
+
"await"
|
|
110
|
+
"break"
|
|
111
|
+
"class"
|
|
112
|
+
"continue"
|
|
113
|
+
"def"
|
|
114
|
+
"del"
|
|
115
|
+
"elif"
|
|
116
|
+
"else"
|
|
117
|
+
"except"
|
|
118
|
+
"exec"
|
|
119
|
+
"finally"
|
|
120
|
+
"for"
|
|
121
|
+
"from"
|
|
122
|
+
"global"
|
|
123
|
+
"if"
|
|
124
|
+
"import"
|
|
125
|
+
"lambda"
|
|
126
|
+
"nonlocal"
|
|
127
|
+
"pass"
|
|
128
|
+
"print"
|
|
129
|
+
"raise"
|
|
130
|
+
"return"
|
|
131
|
+
"try"
|
|
132
|
+
"while"
|
|
133
|
+
"with"
|
|
134
|
+
"yield"
|
|
135
|
+
"match"
|
|
136
|
+
"case"
|
|
137
|
+
] @keyword
|
|
Binary file
|
package/bin/horizon.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "horizon-code",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered trading strategy terminal for Polymarket",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"horizon": "./bin/horizon.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"bin",
|
|
12
|
+
"assets"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "bun run src/index.ts",
|
|
16
|
+
"dev": "bun run --watch src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["polymarket", "trading", "ai", "terminal", "strategy", "horizon"],
|
|
19
|
+
"author": "Oppenheimer Labs",
|
|
20
|
+
"license": "UNLICENSED",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/bun": "latest"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"typescript": "^5"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@ai-sdk/mcp": "^1.0.25",
|
|
29
|
+
"@ai-sdk/provider-utils": "^3.0.0",
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
31
|
+
"@openrouter/ai-sdk-provider": "^2.2.3",
|
|
32
|
+
"@opentui/core": "^0.1.87",
|
|
33
|
+
"@supabase/supabase-js": "^2.99.1",
|
|
34
|
+
"ai": "^6.0.78",
|
|
35
|
+
"zod": "^4.3.6"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"bun": ">=1.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/ai/client.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// AI client — all LLM calls go through the API server (OpenRouter key stays server-side)
|
|
2
|
+
// Tools execute LOCALLY (Python sandbox, file ops, local APIs).
|
|
3
|
+
// Architecture: TUI sends messages → server calls OpenRouter → streams SSE back
|
|
4
|
+
// If LLM calls a tool → TUI executes it → sends result back → server continues
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { createMCPClient } from "@ai-sdk/mcp";
|
|
8
|
+
import { getSystemPrompt } from "./system-prompt.ts";
|
|
9
|
+
import { researchTools } from "../research/tools.ts";
|
|
10
|
+
import { portfolioTools } from "../platform/tools.ts";
|
|
11
|
+
import { strategyTools } from "../strategy/tools.ts";
|
|
12
|
+
import { getAuthUrl, loadConfig } from "../platform/config.ts";
|
|
13
|
+
import type { Mode } from "../components/mode-bar.ts";
|
|
14
|
+
import type { ModelPower } from "../platform/tiers.ts";
|
|
15
|
+
import { MODEL_IDS, PREMIUM_TOOLS, type PlanTier } from "../platform/tiers.ts";
|
|
16
|
+
|
|
17
|
+
let mcpTools: Record<string, any> = {};
|
|
18
|
+
let mcpClient: Awaited<ReturnType<typeof createMCPClient>> | null = null;
|
|
19
|
+
|
|
20
|
+
export async function initMCP(): Promise<{ toolCount: number }> {
|
|
21
|
+
const command = process.env.HORIZON_MCP_COMMAND;
|
|
22
|
+
if (!command) return { toolCount: 0 };
|
|
23
|
+
const args = (process.env.HORIZON_MCP_ARGS ?? "mcp serve").split(" ");
|
|
24
|
+
try {
|
|
25
|
+
// Only pass safe env vars to MCP — no exchange keys, API secrets, or private keys
|
|
26
|
+
const mcpEnv: Record<string, string> = {};
|
|
27
|
+
const mcpAllow = ["PATH", "HOME", "LANG", "LC_ALL", "TERM", "PYTHONPATH", "VIRTUAL_ENV", "CONDA_PREFIX", "SHELL", "USER"];
|
|
28
|
+
for (const k of mcpAllow) { if (process.env[k]) mcpEnv[k] = process.env[k]!; }
|
|
29
|
+
|
|
30
|
+
mcpClient = await createMCPClient({
|
|
31
|
+
transport: { type: "stdio" as any, command, args, env: mcpEnv } as any,
|
|
32
|
+
});
|
|
33
|
+
mcpTools = await mcpClient.tools();
|
|
34
|
+
return { toolCount: Object.keys(mcpTools).length };
|
|
35
|
+
} catch { return { toolCount: 0 }; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function closeMCP(): Promise<void> {
|
|
39
|
+
if (mcpClient) { await mcpClient.close(); mcpClient = null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ChatMessage { role: "user" | "assistant"; content: string; }
|
|
43
|
+
|
|
44
|
+
export type ChatEvent =
|
|
45
|
+
| { type: "thinking" }
|
|
46
|
+
| { type: "text-delta"; textDelta: string }
|
|
47
|
+
| { type: "tool-call"; toolName: string; args: Record<string, unknown> }
|
|
48
|
+
| { type: "tool-result"; toolName: string; result: unknown }
|
|
49
|
+
| { type: "strategy-code"; code: string }
|
|
50
|
+
| { type: "usage"; inputTokens: number; outputTokens: number; weightedTokens: number; model: string }
|
|
51
|
+
| { type: "meta"; tier: string; model: string; effectivePower: string; budgetUsed: number; budgetTotal: number; overflowing: boolean }
|
|
52
|
+
| { type: "error"; message: string }
|
|
53
|
+
| { type: "finish" };
|
|
54
|
+
|
|
55
|
+
// ── Structured output schema for strategy mode ──
|
|
56
|
+
|
|
57
|
+
const strategySchema = z.object({
|
|
58
|
+
response: z.string().describe("Your response to the user in markdown"),
|
|
59
|
+
code: z.string().nullable().describe("Complete Python strategy code (imports + pipeline + hz.run), or null if no code change"),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── Partial JSON field extractor ──
|
|
63
|
+
|
|
64
|
+
function extractJsonStringField(json: string, field: string): string | undefined {
|
|
65
|
+
const pattern = new RegExp(`"${field}"\\s*:\\s*"`);
|
|
66
|
+
const match = json.match(pattern);
|
|
67
|
+
if (!match || match.index === undefined) return undefined;
|
|
68
|
+
|
|
69
|
+
const start = match.index + match[0].length;
|
|
70
|
+
let result = "";
|
|
71
|
+
let i = start;
|
|
72
|
+
|
|
73
|
+
while (i < json.length) {
|
|
74
|
+
const ch = json[i]!;
|
|
75
|
+
if (ch === "\\") {
|
|
76
|
+
if (i + 1 >= json.length) break;
|
|
77
|
+
const next = json[i + 1]!;
|
|
78
|
+
switch (next) {
|
|
79
|
+
case "n": result += "\n"; break;
|
|
80
|
+
case "t": result += "\t"; break;
|
|
81
|
+
case "r": result += "\r"; break;
|
|
82
|
+
case '"': result += '"'; break;
|
|
83
|
+
case "\\": result += "\\"; break;
|
|
84
|
+
case "/": result += "/"; break;
|
|
85
|
+
case "u": {
|
|
86
|
+
if (i + 5 < json.length) {
|
|
87
|
+
const hex = json.slice(i + 2, i + 6);
|
|
88
|
+
const cp = parseInt(hex, 16);
|
|
89
|
+
if (!isNaN(cp)) result += String.fromCharCode(cp);
|
|
90
|
+
i += 4;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
default: result += next; break;
|
|
95
|
+
}
|
|
96
|
+
i += 2;
|
|
97
|
+
} else if (ch === '"') {
|
|
98
|
+
break;
|
|
99
|
+
} else {
|
|
100
|
+
result += ch;
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractCodeField(json: string): string | null | undefined {
|
|
108
|
+
if (json.match(/"code"\s*:\s*null/)) return null;
|
|
109
|
+
return extractJsonStringField(json, "code");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Build tools for a given mode ──
|
|
113
|
+
|
|
114
|
+
function buildTools(mode: Mode): Record<string, any> {
|
|
115
|
+
// All tools available — server handles tier gating via model selection
|
|
116
|
+
return {
|
|
117
|
+
...(mode === "research" ? researchTools : {}),
|
|
118
|
+
...(mode === "strategy" ? { ...strategyTools, ...researchTools, deploy_strategy: portfolioTools.deploy_strategy, stop_strategy: portfolioTools.stop_strategy, list_strategies: portfolioTools.list_strategies, list_credentials: portfolioTools.list_credentials, add_credential: portfolioTools.add_credential } : {}),
|
|
119
|
+
...(mode === "portfolio" ? portfolioTools : {}),
|
|
120
|
+
...mcpTools,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── SSE stream consumer ──
|
|
125
|
+
|
|
126
|
+
async function* consumeSSE(res: Response): AsyncGenerator<Record<string, any>> {
|
|
127
|
+
if (!res.body) return;
|
|
128
|
+
const reader = res.body.getReader();
|
|
129
|
+
const decoder = new TextDecoder();
|
|
130
|
+
let buf = "";
|
|
131
|
+
|
|
132
|
+
while (true) {
|
|
133
|
+
const { done, value } = await reader.read();
|
|
134
|
+
if (done) break;
|
|
135
|
+
buf += decoder.decode(value, { stream: true });
|
|
136
|
+
|
|
137
|
+
const lines = buf.split("\n");
|
|
138
|
+
buf = lines.pop() ?? "";
|
|
139
|
+
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
if (!line.startsWith("data: ")) continue;
|
|
142
|
+
const data = line.slice(6);
|
|
143
|
+
if (data === "[DONE]") return;
|
|
144
|
+
try { yield JSON.parse(data); } catch {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Convert tools to JSON-serializable format for the server ──
|
|
150
|
+
|
|
151
|
+
import { zodSchema } from "@ai-sdk/provider-utils";
|
|
152
|
+
|
|
153
|
+
function toolsToServerFormat(tools: Record<string, any>): Record<string, any> {
|
|
154
|
+
const result: Record<string, any> = {};
|
|
155
|
+
for (const [name, tool] of Object.entries(tools)) {
|
|
156
|
+
if (!tool) continue;
|
|
157
|
+
let params: any = { type: "object", properties: {} };
|
|
158
|
+
try {
|
|
159
|
+
if (tool.parameters) {
|
|
160
|
+
// Use AI SDK's zodSchema which correctly converts Zod v4 to JSON Schema
|
|
161
|
+
const converted = zodSchema(tool.parameters);
|
|
162
|
+
params = converted.jsonSchema ?? params;
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
result[name] = { description: tool.description ?? "", parameters: params };
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Server call — sends messages, receives SSE stream ──
|
|
171
|
+
|
|
172
|
+
// Shared abort controller — cancel from outside to abort the current stream
|
|
173
|
+
let currentAbort: AbortController | null = null;
|
|
174
|
+
export function abortChat(): void { currentAbort?.abort(); }
|
|
175
|
+
|
|
176
|
+
async function callServer(opts: {
|
|
177
|
+
messages: { role: string; content: string }[];
|
|
178
|
+
system: string;
|
|
179
|
+
modelPower: ModelPower;
|
|
180
|
+
tools: Record<string, any>;
|
|
181
|
+
responseFormat?: any;
|
|
182
|
+
signal?: AbortSignal;
|
|
183
|
+
}): Promise<Response> {
|
|
184
|
+
const config = loadConfig();
|
|
185
|
+
if (!config.api_key) throw new Error("Not authenticated. Type /login.");
|
|
186
|
+
|
|
187
|
+
const res = await fetch(`${getAuthUrl()}/api/v1/chat`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
signal: opts.signal ?? AbortSignal.timeout(120000), // 2 min max
|
|
190
|
+
headers: { "Authorization": `Bearer ${config.api_key}`, "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
messages: opts.messages,
|
|
193
|
+
system: opts.system,
|
|
194
|
+
model_power: opts.modelPower,
|
|
195
|
+
tools: toolsToServerFormat(opts.tools),
|
|
196
|
+
response_format: opts.responseFormat,
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
const err = await res.json().catch(() => ({ error: `Server error ${res.status}` })) as any;
|
|
202
|
+
throw new Error(err.message ?? err.error ?? `Error ${res.status}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return res;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Main chat function ──
|
|
209
|
+
|
|
210
|
+
export async function* chat(
|
|
211
|
+
messages: ChatMessage[],
|
|
212
|
+
mode: Mode = "research",
|
|
213
|
+
modelPower?: ModelPower,
|
|
214
|
+
verbosity: string = "normal",
|
|
215
|
+
): AsyncGenerator<ChatEvent> {
|
|
216
|
+
const power = modelPower ?? "standard";
|
|
217
|
+
const config = loadConfig();
|
|
218
|
+
if (!config.api_key) {
|
|
219
|
+
yield { type: "error", message: "Not authenticated. Type /login." };
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const tools = buildTools(mode);
|
|
224
|
+
const system = getSystemPrompt(mode, verbosity);
|
|
225
|
+
const useStructuredOutput = mode === "strategy";
|
|
226
|
+
|
|
227
|
+
// Abort controller — allows cancellation from the UI
|
|
228
|
+
currentAbort = new AbortController();
|
|
229
|
+
const signal = currentAbort.signal;
|
|
230
|
+
|
|
231
|
+
// Build the conversation for the server
|
|
232
|
+
const serverMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
233
|
+
|
|
234
|
+
let step = 0;
|
|
235
|
+
const MAX_STEPS = 5;
|
|
236
|
+
|
|
237
|
+
while (step < MAX_STEPS) {
|
|
238
|
+
step++;
|
|
239
|
+
|
|
240
|
+
let res: Response;
|
|
241
|
+
try {
|
|
242
|
+
res = await callServer({
|
|
243
|
+
messages: serverMessages,
|
|
244
|
+
system,
|
|
245
|
+
modelPower: power,
|
|
246
|
+
tools,
|
|
247
|
+
responseFormat: useStructuredOutput ? { type: "json_schema", schema: { response: "string", code: "string|null" } } : undefined,
|
|
248
|
+
signal,
|
|
249
|
+
});
|
|
250
|
+
} catch (err: any) {
|
|
251
|
+
yield { type: "error", message: err.message };
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Process SSE events
|
|
256
|
+
let hasToolCalls = false;
|
|
257
|
+
let jsonBuf = "";
|
|
258
|
+
let emittedResponseLen = 0;
|
|
259
|
+
let emittedCode: string | null = null;
|
|
260
|
+
let structuredActive = useStructuredOutput;
|
|
261
|
+
const pendingToolCalls: { toolName: string; args: Record<string, unknown> }[] = [];
|
|
262
|
+
let eventCount = 0;
|
|
263
|
+
|
|
264
|
+
for await (const event of consumeSSE(res)) {
|
|
265
|
+
eventCount++;
|
|
266
|
+
if (event.type === "meta") {
|
|
267
|
+
yield {
|
|
268
|
+
type: "meta", tier: event.tier ?? "", model: event.model ?? "",
|
|
269
|
+
effectivePower: event.effective_power ?? power,
|
|
270
|
+
budgetUsed: event.budget_used ?? 0, budgetTotal: event.budget_total ?? 0,
|
|
271
|
+
overflowing: event.overflowing ?? false,
|
|
272
|
+
};
|
|
273
|
+
} else if (event.type === "text-delta") {
|
|
274
|
+
const delta = event.text ?? event.textDelta ?? "";
|
|
275
|
+
if (!delta) continue;
|
|
276
|
+
|
|
277
|
+
if (structuredActive) {
|
|
278
|
+
jsonBuf += delta;
|
|
279
|
+
|
|
280
|
+
const trimmed = jsonBuf.trimStart();
|
|
281
|
+
if (trimmed.length > 3 && !trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
282
|
+
structuredActive = false;
|
|
283
|
+
yield { type: "text-delta", textDelta: jsonBuf };
|
|
284
|
+
jsonBuf = "";
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const response = extractJsonStringField(jsonBuf, "response");
|
|
289
|
+
if (response && response.length > emittedResponseLen) {
|
|
290
|
+
yield { type: "text-delta", textDelta: response.slice(emittedResponseLen) };
|
|
291
|
+
emittedResponseLen = response.length;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const code = extractCodeField(jsonBuf);
|
|
295
|
+
if (code !== undefined && code !== emittedCode) {
|
|
296
|
+
if (code !== null) yield { type: "strategy-code", code };
|
|
297
|
+
emittedCode = code;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
yield { type: "text-delta", textDelta: delta };
|
|
301
|
+
}
|
|
302
|
+
} else if (event.type === "tool-call") {
|
|
303
|
+
hasToolCalls = true;
|
|
304
|
+
const toolName = event.toolName ?? "";
|
|
305
|
+
const args = event.args ?? {};
|
|
306
|
+
pendingToolCalls.push({ toolName, args });
|
|
307
|
+
yield { type: "tool-call", toolName, args };
|
|
308
|
+
} else if (event.type === "usage") {
|
|
309
|
+
yield {
|
|
310
|
+
type: "usage",
|
|
311
|
+
inputTokens: event.input_tokens ?? 0,
|
|
312
|
+
outputTokens: event.output_tokens ?? 0,
|
|
313
|
+
weightedTokens: event.weighted_tokens ?? 0,
|
|
314
|
+
model: event.model ?? "",
|
|
315
|
+
};
|
|
316
|
+
} else if (event.type === "error") {
|
|
317
|
+
yield { type: "error", message: event.message ?? "Server error" };
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// If no tool calls, we're done
|
|
323
|
+
if (!hasToolCalls || pendingToolCalls.length === 0) {
|
|
324
|
+
if (eventCount === 0) {
|
|
325
|
+
yield { type: "error", message: "No response from server. Check your connection or try again." };
|
|
326
|
+
}
|
|
327
|
+
yield { type: "finish" };
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Execute tools locally and build the assistant's "I called these tools" message
|
|
332
|
+
const assistantParts: string[] = [];
|
|
333
|
+
for (const tc of pendingToolCalls) {
|
|
334
|
+
const tool = tools[tc.toolName];
|
|
335
|
+
if (!tool) {
|
|
336
|
+
const errorResult = { error: `Unknown tool: ${tc.toolName}` };
|
|
337
|
+
yield { type: "tool-result", toolName: tc.toolName, result: errorResult };
|
|
338
|
+
assistantParts.push(`I called ${tc.toolName} but it was not found.`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const result = await tool.execute(tc.args, { toolCallId: `call-${Date.now()}`, messages: [] });
|
|
344
|
+
yield { type: "tool-result", toolName: tc.toolName, result };
|
|
345
|
+
const summary = typeof result === "object" ? JSON.stringify(result).slice(0, 2000) : String(result);
|
|
346
|
+
assistantParts.push(`I called ${tc.toolName}(${JSON.stringify(tc.args).slice(0, 100)}) and got:\n${summary}`);
|
|
347
|
+
} catch (err: any) {
|
|
348
|
+
const errorResult = { error: err.message ?? String(err) };
|
|
349
|
+
yield { type: "tool-result", toolName: tc.toolName, result: errorResult };
|
|
350
|
+
assistantParts.push(`I called ${tc.toolName} but it failed: ${err.message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Add as assistant message — the LLM sees it already ran the tools
|
|
355
|
+
serverMessages.push({ role: "assistant", content: assistantParts.join("\n\n") });
|
|
356
|
+
// Prompt the LLM to continue with its response based on the tool results
|
|
357
|
+
serverMessages.push({ role: "user", content: "Now respond to the user based on the tool results above. Do NOT call the same tools again." });
|
|
358
|
+
|
|
359
|
+
// Reset structured output state for next turn
|
|
360
|
+
if (structuredActive) {
|
|
361
|
+
jsonBuf = "";
|
|
362
|
+
emittedResponseLen = 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Loop back — server will call OpenRouter again with the full conversation including tool results
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
yield { type: "finish" };
|
|
369
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Mode } from "../components/mode-bar.ts";
|
|
2
|
+
import { buildGeneratePrompt } from "../strategy/prompts.ts";
|
|
3
|
+
|
|
4
|
+
const BASE = `You are Horizon, an AI trading research assistant running in a CLI terminal.
|
|
5
|
+
|
|
6
|
+
Rules:
|
|
7
|
+
- Concise and direct. You're a terminal, not a chatbot.
|
|
8
|
+
- Tool results render as rich CLI widgets automatically — do NOT reformat the data as tables. Just add 1-2 sentences of insight after the widget.
|
|
9
|
+
- NEVER suggest switching modes.
|
|
10
|
+
- Format: $102,450 not 102450.
|
|
11
|
+
- NEVER answer questions about markets, events, prices, or predictions from memory. ALWAYS call tools to get real-time data. Your training data is stale — the tools have live data.`;
|
|
12
|
+
|
|
13
|
+
const MODE_PROMPTS: Record<Mode, string> = {
|
|
14
|
+
research: `${BASE}
|
|
15
|
+
|
|
16
|
+
## Mode: Research
|
|
17
|
+
|
|
18
|
+
You have 13 research tools that fetch LIVE data from Polymarket, Kalshi, and the web. Use them.
|
|
19
|
+
|
|
20
|
+
### CRITICAL RULE
|
|
21
|
+
When the user mentions ANY topic, event, market, or asks about what's happening — ALWAYS call a tool to search for real data. Never answer from your training data alone. Prediction markets change by the minute.
|
|
22
|
+
|
|
23
|
+
- "What's happening with Iran?" → polymarketEvents("iran")
|
|
24
|
+
- "Show me crypto markets" → polymarketEvents("crypto")
|
|
25
|
+
- "Tell me about the fed rate market" → polymarketEvents("fed rate"), then polymarketEventDetail with the slug
|
|
26
|
+
- "What's the sentiment on bitcoin?" → newsSentiment with the slug from a prior search
|
|
27
|
+
- "How much should I bet?" → evCalculator with the slug, side, and edge
|
|
28
|
+
|
|
29
|
+
### Available Tools
|
|
30
|
+
|
|
31
|
+
**Search & discover:**
|
|
32
|
+
- polymarketEvents(query, limit) — search Polymarket by topic. ALWAYS call this first when the user asks about any topic.
|
|
33
|
+
- kalshiEvents(query) — search Kalshi markets
|
|
34
|
+
- webSearch(query) — search the internet for news, facts, context
|
|
35
|
+
- calaKnowledge(query) — deep research on companies, people, industries
|
|
36
|
+
|
|
37
|
+
**Analyze a specific market (requires a slug from polymarketEvents):**
|
|
38
|
+
- polymarketEventDetail(slug) — full details, sub-markets, price history, spreads
|
|
39
|
+
- polymarketPriceHistory(slug, interval) — price chart (1h/6h/1d/1w/1m/max)
|
|
40
|
+
- polymarketOrderBook(slug) — bid/ask depth
|
|
41
|
+
- whaleTracker(slug) — large trades, smart money flow
|
|
42
|
+
- newsSentiment(slug) — news sentiment score + articles
|
|
43
|
+
- historicalVolatility(slug, interval) — realized vol, regime detection
|
|
44
|
+
|
|
45
|
+
**Quantitative analysis:**
|
|
46
|
+
- evCalculator(slug, side, estimatedEdge, bankroll) — expected value + Kelly sizing
|
|
47
|
+
- probabilityCalculator(slugA, slugB) — joint/conditional probability
|
|
48
|
+
- compareMarkets(topic) — cross-platform price comparison (Polymarket vs Kalshi)
|
|
49
|
+
|
|
50
|
+
### Tool Chaining
|
|
51
|
+
When the user refers to a market from a previous result, reuse the SLUG from that result. Don't call polymarketEvents again — go straight to the detail tool. Look at conversation history for slugs.
|
|
52
|
+
|
|
53
|
+
Use your intelligence to understand what the user wants. If they say "dig deeper into the first one" — that means polymarketEventDetail with the first slug. If they say "what do whales think" — that means whaleTracker. You're smart enough to figure this out without a flowchart.`,
|
|
54
|
+
|
|
55
|
+
strategy: "", // dynamically generated
|
|
56
|
+
|
|
57
|
+
portfolio: `${BASE}
|
|
58
|
+
|
|
59
|
+
## Mode: Portfolio Management
|
|
60
|
+
|
|
61
|
+
You have these tools to access the user's REAL account data. Always call tools — never guess.
|
|
62
|
+
|
|
63
|
+
- **list_strategies** — all strategies with status. Call this first.
|
|
64
|
+
- **get_strategy(strategy_id)** — full detail (code, params, risk config)
|
|
65
|
+
- **list_deployments(strategy_id)** — all deployments (active, stopped, errored)
|
|
66
|
+
- **get_metrics(strategy_id)** — live P&L, positions, orders, win rate, drawdown
|
|
67
|
+
- **get_logs(strategy_id)** — recent stdout/stderr from running bot
|
|
68
|
+
- **deploy_strategy(strategy_id, credential_id, dry_run)** — deploy (paper or live)
|
|
69
|
+
- **stop_strategy(strategy_id)** — stop active deployment
|
|
70
|
+
- **validate_code(code)** — validate strategy code
|
|
71
|
+
|
|
72
|
+
When the user asks about their portfolio, strategies, or deployments — call the tools immediately. The data is real and live. Format P&L, exposure, and drawdown clearly. Flag high drawdown or errors.`,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const VERBOSITY_PREFIX: Record<string, string> = {
|
|
76
|
+
short: "\n\nIMPORTANT: Be extremely terse. Maximum 1-2 sentences per response. No elaboration unless asked. Bullet points over paragraphs.",
|
|
77
|
+
normal: "",
|
|
78
|
+
verbose: "\n\nBe thorough and detailed. Explain your reasoning, provide context, and be comprehensive. Use examples when helpful.",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function getSystemPrompt(mode: Mode, verbosity: string = "normal"): string {
|
|
82
|
+
// Strategy mode has its own verbosity rules — don't override
|
|
83
|
+
if (mode === "strategy") return buildGeneratePrompt();
|
|
84
|
+
const prefix = VERBOSITY_PREFIX[verbosity] ?? "";
|
|
85
|
+
return MODE_PROMPTS[mode] + prefix;
|
|
86
|
+
}
|