polymath-agent 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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/cli.js +1764 -0
- package/package.json +62 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { createElement } from "react";
|
|
7
|
+
import { render } from "ink";
|
|
8
|
+
|
|
9
|
+
// src/config/store.ts
|
|
10
|
+
import fs2 from "node:fs";
|
|
11
|
+
|
|
12
|
+
// src/config/paths.ts
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
function configDir() {
|
|
17
|
+
const override = process.env.POLYMATH_HOME;
|
|
18
|
+
if (override) return override;
|
|
19
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
20
|
+
const base = xdg && xdg.trim() ? xdg : path.join(os.homedir(), ".config");
|
|
21
|
+
return path.join(base, "polymath");
|
|
22
|
+
}
|
|
23
|
+
function ensureConfigDir() {
|
|
24
|
+
const dir = configDir();
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
function configFilePath() {
|
|
29
|
+
return path.join(configDir(), "config.json");
|
|
30
|
+
}
|
|
31
|
+
function dbFilePath() {
|
|
32
|
+
return path.join(configDir(), "usage.sqlite");
|
|
33
|
+
}
|
|
34
|
+
function modelCachePath() {
|
|
35
|
+
return path.join(configDir(), "models.cache.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/config/store.ts
|
|
39
|
+
var DEFAULT_CONFIG = {
|
|
40
|
+
defaultObjective: "value",
|
|
41
|
+
referer: "https://github.com/polymath-agent",
|
|
42
|
+
title: "Polymath",
|
|
43
|
+
firestore: {
|
|
44
|
+
enabled: false,
|
|
45
|
+
projectId: "mathology-b8e3d",
|
|
46
|
+
collection: "polymath_usage"
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
function loadConfig() {
|
|
50
|
+
const file = configFilePath();
|
|
51
|
+
if (!fs2.existsSync(file)) return { ...DEFAULT_CONFIG };
|
|
52
|
+
try {
|
|
53
|
+
const raw = JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
54
|
+
return {
|
|
55
|
+
...DEFAULT_CONFIG,
|
|
56
|
+
...raw,
|
|
57
|
+
firestore: { ...DEFAULT_CONFIG.firestore, ...raw.firestore ?? {} }
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return { ...DEFAULT_CONFIG };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function saveConfig(config) {
|
|
64
|
+
ensureConfigDir();
|
|
65
|
+
const file = configFilePath();
|
|
66
|
+
fs2.writeFileSync(file, JSON.stringify(config, null, 2), { mode: 384 });
|
|
67
|
+
try {
|
|
68
|
+
fs2.chmodSync(file, 384);
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function resolveApiKey(config) {
|
|
73
|
+
return process.env.OPENROUTER_API_KEY?.trim() || config.openrouterApiKey;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/providers/openrouter.ts
|
|
77
|
+
var BASE = globalThis.process?.env?.OPENROUTER_BASE_URL?.replace(/\/$/, "") || "https://openrouter.ai/api/v1";
|
|
78
|
+
var OpenRouterError = class extends Error {
|
|
79
|
+
status;
|
|
80
|
+
constructor(message, status) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "OpenRouterError";
|
|
83
|
+
this.status = status;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var OpenRouterClient = class {
|
|
87
|
+
apiKey;
|
|
88
|
+
referer;
|
|
89
|
+
title;
|
|
90
|
+
constructor(opts = {}) {
|
|
91
|
+
this.apiKey = opts.apiKey;
|
|
92
|
+
this.referer = opts.referer ?? "https://github.com/polymath-agent";
|
|
93
|
+
this.title = opts.title ?? "Polymath";
|
|
94
|
+
}
|
|
95
|
+
headers(json = true) {
|
|
96
|
+
const h = {
|
|
97
|
+
Authorization: `Bearer ${this.apiKey ?? ""}`,
|
|
98
|
+
"HTTP-Referer": this.referer,
|
|
99
|
+
"X-Title": this.title
|
|
100
|
+
};
|
|
101
|
+
if (json) h["Content-Type"] = "application/json";
|
|
102
|
+
return h;
|
|
103
|
+
}
|
|
104
|
+
/** Raw /models payload (no auth required). */
|
|
105
|
+
async listRawModels() {
|
|
106
|
+
const res = await fetch(`${BASE}/models`, { headers: this.headers(false) });
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw new OpenRouterError(`Failed to list models (${res.status})`, res.status);
|
|
109
|
+
}
|
|
110
|
+
const json = await res.json();
|
|
111
|
+
return json.data ?? [];
|
|
112
|
+
}
|
|
113
|
+
/** Validate the configured key; returns key metadata or throws. */
|
|
114
|
+
async validateKey() {
|
|
115
|
+
if (!this.apiKey) throw new OpenRouterError("No API key set");
|
|
116
|
+
const res = await fetch(`${BASE}/key`, { headers: this.headers(false) });
|
|
117
|
+
if (res.status === 401) throw new OpenRouterError("Invalid API key (401)", 401);
|
|
118
|
+
if (!res.ok) throw new OpenRouterError(`Key check failed (${res.status})`, res.status);
|
|
119
|
+
const json = await res.json();
|
|
120
|
+
const d = json.data ?? {};
|
|
121
|
+
return { label: d.label, usage: d.usage, limit: d.limit };
|
|
122
|
+
}
|
|
123
|
+
buildBody(req, stream) {
|
|
124
|
+
return {
|
|
125
|
+
model: req.model,
|
|
126
|
+
messages: req.messages.map(serializeMessage),
|
|
127
|
+
...req.tools && req.tools.length ? { tools: req.tools, tool_choice: "auto" } : {},
|
|
128
|
+
temperature: req.temperature ?? 0.2,
|
|
129
|
+
...req.maxTokens ? { max_tokens: req.maxTokens } : {},
|
|
130
|
+
stream,
|
|
131
|
+
usage: { include: true }
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Non-streaming completion. costUsd is computed from `pricing` (deterministic). */
|
|
135
|
+
async complete(req, pricing) {
|
|
136
|
+
if (!this.apiKey) throw new OpenRouterError("No API key set. Run `poly login`.");
|
|
137
|
+
const res = await fetch(`${BASE}/chat/completions`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: this.headers(),
|
|
140
|
+
body: JSON.stringify(this.buildBody(req, false))
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const text = await res.text().catch(() => "");
|
|
144
|
+
throw new OpenRouterError(`Completion failed (${res.status}): ${truncate(text)}`, res.status);
|
|
145
|
+
}
|
|
146
|
+
const json = await res.json();
|
|
147
|
+
if (json?.error) {
|
|
148
|
+
throw new OpenRouterError(json.error.message ?? "Provider error", json.error.code);
|
|
149
|
+
}
|
|
150
|
+
const choice = json.choices?.[0] ?? {};
|
|
151
|
+
const msg = choice.message ?? {};
|
|
152
|
+
const usage = {
|
|
153
|
+
promptTokens: json.usage?.prompt_tokens ?? 0,
|
|
154
|
+
completionTokens: json.usage?.completion_tokens ?? 0,
|
|
155
|
+
totalTokens: json.usage?.total_tokens ?? 0
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
content: typeof msg.content === "string" ? msg.content : "",
|
|
159
|
+
toolCalls: parseToolCalls(msg.tool_calls),
|
|
160
|
+
usage,
|
|
161
|
+
model: json.model ?? req.model,
|
|
162
|
+
costUsd: computeCost(usage, pricing, json.usage?.cost),
|
|
163
|
+
finishReason: choice.finish_reason ?? null
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Streaming completion. Yields text deltas; returns the full CompletionResult.
|
|
168
|
+
* Tool-call deltas are accumulated and surfaced in the final result.
|
|
169
|
+
*/
|
|
170
|
+
async *stream(req, pricing) {
|
|
171
|
+
if (!this.apiKey) throw new OpenRouterError("No API key set. Run `poly login`.");
|
|
172
|
+
const res = await fetch(`${BASE}/chat/completions`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: this.headers(),
|
|
175
|
+
body: JSON.stringify(this.buildBody(req, true))
|
|
176
|
+
});
|
|
177
|
+
if (!res.ok || !res.body) {
|
|
178
|
+
const text = await res.text().catch(() => "");
|
|
179
|
+
throw new OpenRouterError(`Stream failed (${res.status}): ${truncate(text)}`, res.status);
|
|
180
|
+
}
|
|
181
|
+
const reader = res.body.getReader();
|
|
182
|
+
const decoder = new TextDecoder();
|
|
183
|
+
let buffer = "";
|
|
184
|
+
let content = "";
|
|
185
|
+
const toolAcc = /* @__PURE__ */ new Map();
|
|
186
|
+
let usageJson = null;
|
|
187
|
+
let finishReason = null;
|
|
188
|
+
let model = req.model;
|
|
189
|
+
while (true) {
|
|
190
|
+
const { done, value } = await reader.read();
|
|
191
|
+
if (done) break;
|
|
192
|
+
buffer += decoder.decode(value, { stream: true });
|
|
193
|
+
const lines = buffer.split("\n");
|
|
194
|
+
buffer = lines.pop() ?? "";
|
|
195
|
+
for (const line of lines) {
|
|
196
|
+
const trimmed = line.trim();
|
|
197
|
+
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
|
198
|
+
const data = trimmed.slice(5).trim();
|
|
199
|
+
if (data === "[DONE]") continue;
|
|
200
|
+
let evt;
|
|
201
|
+
try {
|
|
202
|
+
evt = JSON.parse(data);
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (evt?.error) {
|
|
207
|
+
throw new OpenRouterError(evt.error.message ?? "Stream provider error", evt.error.code);
|
|
208
|
+
}
|
|
209
|
+
if (evt.model) model = evt.model;
|
|
210
|
+
if (evt.usage) usageJson = evt.usage;
|
|
211
|
+
const choice = evt.choices?.[0];
|
|
212
|
+
if (!choice) continue;
|
|
213
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
214
|
+
const delta = choice.delta ?? {};
|
|
215
|
+
if (typeof delta.content === "string" && delta.content) {
|
|
216
|
+
content += delta.content;
|
|
217
|
+
yield delta.content;
|
|
218
|
+
}
|
|
219
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
220
|
+
for (const tc of delta.tool_calls) {
|
|
221
|
+
const idx = tc.index ?? 0;
|
|
222
|
+
const cur = toolAcc.get(idx) ?? { id: "", name: "", args: "" };
|
|
223
|
+
if (tc.id) cur.id = tc.id;
|
|
224
|
+
if (tc.function?.name) cur.name = tc.function.name;
|
|
225
|
+
if (tc.function?.arguments) cur.args += tc.function.arguments;
|
|
226
|
+
toolAcc.set(idx, cur);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const usage = {
|
|
232
|
+
promptTokens: usageJson?.prompt_tokens ?? 0,
|
|
233
|
+
completionTokens: usageJson?.completion_tokens ?? 0,
|
|
234
|
+
totalTokens: usageJson?.total_tokens ?? 0
|
|
235
|
+
};
|
|
236
|
+
const toolCalls = [...toolAcc.values()].filter((t) => t.name).map((t) => ({
|
|
237
|
+
id: t.id || `call_${t.name}`,
|
|
238
|
+
type: "function",
|
|
239
|
+
function: { name: t.name, arguments: t.args || "{}" }
|
|
240
|
+
}));
|
|
241
|
+
return {
|
|
242
|
+
content,
|
|
243
|
+
toolCalls,
|
|
244
|
+
usage,
|
|
245
|
+
model,
|
|
246
|
+
costUsd: computeCost(usage, pricing, usageJson?.cost),
|
|
247
|
+
finishReason
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
function serializeMessage(m) {
|
|
252
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
253
|
+
return { role: "assistant", content: m.content ?? "", tool_calls: m.tool_calls };
|
|
254
|
+
}
|
|
255
|
+
if (m.role === "tool") {
|
|
256
|
+
return { role: "tool", tool_call_id: m.tool_call_id, content: m.content };
|
|
257
|
+
}
|
|
258
|
+
return { role: m.role, content: m.content };
|
|
259
|
+
}
|
|
260
|
+
function parseToolCalls(raw) {
|
|
261
|
+
if (!Array.isArray(raw)) return [];
|
|
262
|
+
return raw.map((t) => ({
|
|
263
|
+
id: t.id ?? `call_${t.function?.name ?? "fn"}`,
|
|
264
|
+
type: "function",
|
|
265
|
+
function: {
|
|
266
|
+
name: t.function?.name ?? "",
|
|
267
|
+
arguments: t.function?.arguments ?? "{}"
|
|
268
|
+
}
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
function computeCost(usage, pricing, providerCost) {
|
|
272
|
+
if (typeof providerCost === "number") return providerCost;
|
|
273
|
+
return usage.promptTokens / 1e6 * pricing.promptUsdPerMTok + usage.completionTokens / 1e6 * pricing.completionUsdPerMTok;
|
|
274
|
+
}
|
|
275
|
+
function truncate(s, n = 240) {
|
|
276
|
+
return s.length > n ? s.slice(0, n) + "\u2026" : s;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/models/registry.ts
|
|
280
|
+
import fs3 from "node:fs";
|
|
281
|
+
|
|
282
|
+
// src/models/tiers.ts
|
|
283
|
+
var FRONTIER_PATTERNS = [
|
|
284
|
+
/claude.*(sonnet|opus)/,
|
|
285
|
+
/\bgpt-4o\b(?!-mini)/,
|
|
286
|
+
/\bgpt-4\.1\b(?!-mini|-nano)/,
|
|
287
|
+
/\bgpt-5\b/,
|
|
288
|
+
/\bo[1-4]\b/,
|
|
289
|
+
// o1 / o3 / o4 reasoning models
|
|
290
|
+
/gemini-(1\.5|2\.0|2\.5)-pro/,
|
|
291
|
+
/grok-[2-9]/,
|
|
292
|
+
/deepseek.*(r1|reasoner)/,
|
|
293
|
+
/llama-3\.1-405b/,
|
|
294
|
+
/llama-4-(maverick|behemoth)/,
|
|
295
|
+
/qwen.*(max|235b|plus)/,
|
|
296
|
+
/mistral-large/,
|
|
297
|
+
/command-(r-plus|a)\b/
|
|
298
|
+
];
|
|
299
|
+
var SMALL_PATTERNS = [
|
|
300
|
+
/mini/,
|
|
301
|
+
/flash/,
|
|
302
|
+
/haiku/,
|
|
303
|
+
/lite/,
|
|
304
|
+
/nano/,
|
|
305
|
+
/small/,
|
|
306
|
+
/-(1|2|3|7|8|9)b\b/,
|
|
307
|
+
/\b(1|2|3|7|8|9)b-/
|
|
308
|
+
];
|
|
309
|
+
function classifyTier(idOrName, completionPerMTok) {
|
|
310
|
+
const s = idOrName.toLowerCase();
|
|
311
|
+
const isFrontierFamily = FRONTIER_PATTERNS.some((re) => re.test(s));
|
|
312
|
+
const isSmallFamily = SMALL_PATTERNS.some((re) => re.test(s));
|
|
313
|
+
if (isFrontierFamily && !isSmallFamily) return "frontier";
|
|
314
|
+
if (!isSmallFamily && completionPerMTok >= 9) return "frontier";
|
|
315
|
+
if (completionPerMTok <= 1.5 || isSmallFamily) {
|
|
316
|
+
return completionPerMTok > 6 ? "standard" : "cheap";
|
|
317
|
+
}
|
|
318
|
+
return "standard";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/models/parse.ts
|
|
322
|
+
function findModel(models, id) {
|
|
323
|
+
return models.find((m) => m.id === id);
|
|
324
|
+
}
|
|
325
|
+
function toPerMTok(raw) {
|
|
326
|
+
const n = typeof raw === "string" ? parseFloat(raw) : raw ?? 0;
|
|
327
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
328
|
+
return n * 1e6;
|
|
329
|
+
}
|
|
330
|
+
function parseModels(raw) {
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const m of raw) {
|
|
333
|
+
if (!m?.id) continue;
|
|
334
|
+
const promptUsdPerMTok = toPerMTok(m.pricing?.prompt);
|
|
335
|
+
const completionUsdPerMTok = toPerMTok(m.pricing?.completion);
|
|
336
|
+
const provider = String(m.id).split("/")[0] ?? "unknown";
|
|
337
|
+
const modalities = m.architecture?.input_modalities ?? (typeof m.architecture?.modality === "string" ? String(m.architecture.modality).split(/[+\->]/) : []);
|
|
338
|
+
const supported = m.supported_parameters ?? [];
|
|
339
|
+
out.push({
|
|
340
|
+
id: m.id,
|
|
341
|
+
name: m.name ?? m.id,
|
|
342
|
+
provider,
|
|
343
|
+
contextLength: m.context_length ?? m.top_provider?.context_length ?? 0,
|
|
344
|
+
pricing: { promptUsdPerMTok, completionUsdPerMTok },
|
|
345
|
+
tier: classifyTier(`${m.id} ${m.name ?? ""}`, completionUsdPerMTok),
|
|
346
|
+
capabilities: {
|
|
347
|
+
tools: supported.includes("tools") || supported.includes("tool_choice"),
|
|
348
|
+
vision: modalities.some((x) => /image|vision/i.test(x))
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/models/registry.ts
|
|
356
|
+
var CACHE_TTL_MS = 1e3 * 60 * 60 * 12;
|
|
357
|
+
function loadCachedModels(maxAgeMs = CACHE_TTL_MS) {
|
|
358
|
+
try {
|
|
359
|
+
const file = modelCachePath();
|
|
360
|
+
if (!fs3.existsSync(file)) return null;
|
|
361
|
+
const cache = JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
362
|
+
if (Date.now() - cache.fetchedAt > maxAgeMs) return null;
|
|
363
|
+
return cache.models;
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function writeModelCache(models) {
|
|
369
|
+
ensureConfigDir();
|
|
370
|
+
const cache = { fetchedAt: Date.now(), models };
|
|
371
|
+
fs3.writeFileSync(modelCachePath(), JSON.stringify(cache));
|
|
372
|
+
}
|
|
373
|
+
async function getModels(client2, opts = {}) {
|
|
374
|
+
if (!opts.refresh) {
|
|
375
|
+
const cached = loadCachedModels();
|
|
376
|
+
if (cached && cached.length) return cached;
|
|
377
|
+
}
|
|
378
|
+
const raw = await client2.listRawModels();
|
|
379
|
+
const models = parseModels(raw);
|
|
380
|
+
writeModelCache(models);
|
|
381
|
+
return models;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/auth/onboarding.ts
|
|
385
|
+
import readline from "node:readline";
|
|
386
|
+
|
|
387
|
+
// src/util/format.ts
|
|
388
|
+
import pc from "picocolors";
|
|
389
|
+
function usd(amount) {
|
|
390
|
+
if (amount === 0) return "$0";
|
|
391
|
+
if (amount < 1e-4) return "<$0.0001";
|
|
392
|
+
if (amount < 1) return "$" + amount.toFixed(4);
|
|
393
|
+
if (amount < 100) return "$" + amount.toFixed(3);
|
|
394
|
+
return "$" + amount.toFixed(2);
|
|
395
|
+
}
|
|
396
|
+
function perMTok(usdPerMTok) {
|
|
397
|
+
if (usdPerMTok === 0) return "free";
|
|
398
|
+
if (usdPerMTok < 1) return "$" + usdPerMTok.toFixed(3) + "/M";
|
|
399
|
+
return "$" + usdPerMTok.toFixed(2) + "/M";
|
|
400
|
+
}
|
|
401
|
+
function tokens(n) {
|
|
402
|
+
if (n < 1e3) return String(n);
|
|
403
|
+
if (n < 1e6) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "k";
|
|
404
|
+
return (n / 1e6).toFixed(2).replace(/\.00$/, "") + "M";
|
|
405
|
+
}
|
|
406
|
+
function tierColor(tier, text) {
|
|
407
|
+
const t = text ?? tier;
|
|
408
|
+
switch (tier) {
|
|
409
|
+
case "cheap":
|
|
410
|
+
return pc.green(t);
|
|
411
|
+
case "standard":
|
|
412
|
+
return pc.yellow(t);
|
|
413
|
+
case "frontier":
|
|
414
|
+
return pc.magenta(t);
|
|
415
|
+
default:
|
|
416
|
+
return t;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function table(headers, rows) {
|
|
420
|
+
const widths = headers.map(
|
|
421
|
+
(h, i) => Math.max(visibleLen(h), ...rows.map((r) => visibleLen(r[i] ?? "")))
|
|
422
|
+
);
|
|
423
|
+
const sep = " ";
|
|
424
|
+
const fmtRow = (cells) => cells.map((c2, i) => pad(c2, widths[i])).join(sep).trimEnd();
|
|
425
|
+
const lines = [
|
|
426
|
+
pc.bold(fmtRow(headers)),
|
|
427
|
+
widths.map((w) => "\u2500".repeat(w)).join(sep),
|
|
428
|
+
...rows.map(fmtRow)
|
|
429
|
+
];
|
|
430
|
+
return lines.join("\n");
|
|
431
|
+
}
|
|
432
|
+
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
433
|
+
function visibleLen(s) {
|
|
434
|
+
return s.replace(ANSI, "").length;
|
|
435
|
+
}
|
|
436
|
+
function pad(s, width) {
|
|
437
|
+
const extra = width - visibleLen(s);
|
|
438
|
+
return extra > 0 ? s + " ".repeat(extra) : s;
|
|
439
|
+
}
|
|
440
|
+
var c = pc;
|
|
441
|
+
|
|
442
|
+
// src/auth/onboarding.ts
|
|
443
|
+
function ask(query, opts = {}) {
|
|
444
|
+
return new Promise((resolve2) => {
|
|
445
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
446
|
+
if (opts.hidden) {
|
|
447
|
+
const out = rl.output;
|
|
448
|
+
rl._writeToOutput = (str) => {
|
|
449
|
+
if (str.includes(query) || str.includes("\n") || str.includes("\r")) out.write(str);
|
|
450
|
+
else out.write("*");
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
rl.question(query, (answer) => {
|
|
454
|
+
rl.close();
|
|
455
|
+
if (opts.hidden) process.stdout.write("\n");
|
|
456
|
+
resolve2(answer.trim());
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
async function runLogin() {
|
|
461
|
+
const config = loadConfig();
|
|
462
|
+
const existing = resolveApiKey(config);
|
|
463
|
+
console.log(c.bold("\n\u{1F50C} Connect Polymath to your models (via OpenRouter)\n"));
|
|
464
|
+
console.log(
|
|
465
|
+
[
|
|
466
|
+
"Polymath reaches 300+ models from every major provider through a single OpenRouter key.",
|
|
467
|
+
"",
|
|
468
|
+
c.bold("1.") + " Create a key (free to sign up): " + c.cyan("https://openrouter.ai/keys"),
|
|
469
|
+
c.bold("2.") + " Add credit or use free models: " + c.cyan("https://openrouter.ai/credits"),
|
|
470
|
+
c.bold("3.") + " Paste the key (starts with " + c.dim("sk-or-...") + ") below.",
|
|
471
|
+
"",
|
|
472
|
+
c.dim("The key is stored locally at ~/.config/polymath/config.json (chmod 600) and never sent anywhere except OpenRouter."),
|
|
473
|
+
""
|
|
474
|
+
].join("\n")
|
|
475
|
+
);
|
|
476
|
+
if (existing) {
|
|
477
|
+
const mask = existing.slice(0, 8) + "\u2026" + existing.slice(-4);
|
|
478
|
+
const keep = await ask(`A key is already configured (${mask}). Replace it? [y/N] `);
|
|
479
|
+
if (!/^y/i.test(keep)) {
|
|
480
|
+
console.log(c.dim("Keeping existing key."));
|
|
481
|
+
return existing;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const key = await ask("OpenRouter API key: ", { hidden: true });
|
|
485
|
+
if (!key) {
|
|
486
|
+
console.log(c.yellow("No key entered \u2014 aborted."));
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
if (!/^sk-or-/.test(key)) {
|
|
490
|
+
console.log(c.yellow("Warning: key does not start with 'sk-or-'. Continuing anyway."));
|
|
491
|
+
}
|
|
492
|
+
process.stdout.write("Validating\u2026 ");
|
|
493
|
+
const client2 = new OpenRouterClient({ apiKey: key, referer: config.referer, title: config.title });
|
|
494
|
+
let ok = false;
|
|
495
|
+
let detail = "";
|
|
496
|
+
try {
|
|
497
|
+
const info = await client2.validateKey();
|
|
498
|
+
ok = true;
|
|
499
|
+
const used = typeof info.usage === "number" ? `$${info.usage.toFixed(2)} used` : "";
|
|
500
|
+
const limit = info.limit == null ? "no preset limit" : `$${info.limit} limit`;
|
|
501
|
+
detail = [info.label, used, limit].filter(Boolean).join(" \xB7 ");
|
|
502
|
+
} catch (err) {
|
|
503
|
+
console.log(c.red("failed."));
|
|
504
|
+
console.log(c.red(` ${err?.message ?? err}`));
|
|
505
|
+
const save = await ask("Save the key anyway (e.g. offline)? [y/N] ");
|
|
506
|
+
if (!/^y/i.test(save)) return null;
|
|
507
|
+
}
|
|
508
|
+
config.openrouterApiKey = key;
|
|
509
|
+
saveConfig(config);
|
|
510
|
+
if (ok) console.log(c.green("ok") + c.dim(detail ? ` (${detail})` : ""));
|
|
511
|
+
console.log(c.green("\u2713 Saved. You're connected.") + c.dim(' Try: poly recommend "add a dark-mode toggle"'));
|
|
512
|
+
return key;
|
|
513
|
+
}
|
|
514
|
+
async function ensureApiKey(config) {
|
|
515
|
+
const existing = resolveApiKey(config);
|
|
516
|
+
if (existing) return existing;
|
|
517
|
+
console.log(c.yellow("No OpenRouter API key found \u2014 let's connect one."));
|
|
518
|
+
return runLogin();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/planner/tasks.ts
|
|
522
|
+
var TASK_SPECS = {
|
|
523
|
+
plan: { type: "plan", minTier: "standard", needsTools: false, label: "Plan / decompose" },
|
|
524
|
+
search: { type: "search", minTier: "cheap", needsTools: true, label: "Search codebase" },
|
|
525
|
+
read: { type: "read", minTier: "cheap", needsTools: true, label: "Read & understand" },
|
|
526
|
+
edit: { type: "edit", minTier: "standard", needsTools: true, label: "Edit code" },
|
|
527
|
+
command: { type: "command", minTier: "cheap", needsTools: true, label: "Run command" },
|
|
528
|
+
review: { type: "review", minTier: "frontier", needsTools: false, label: "Review / critique" },
|
|
529
|
+
reason: { type: "reason", minTier: "frontier", needsTools: false, label: "Hard reasoning" },
|
|
530
|
+
explain: { type: "explain", minTier: "cheap", needsTools: false, label: "Explain" },
|
|
531
|
+
summarize: { type: "summarize", minTier: "cheap", needsTools: false, label: "Summarize" },
|
|
532
|
+
chat: { type: "chat", minTier: "cheap", needsTools: false, label: "Chat" }
|
|
533
|
+
};
|
|
534
|
+
var ALL_TASK_TYPES = Object.keys(TASK_SPECS);
|
|
535
|
+
|
|
536
|
+
// src/planner/planner.ts
|
|
537
|
+
var PLAN_SYSTEM = `You are the planning stage of a coding agent. Break the user's request into a short, ordered list of concrete steps.
|
|
538
|
+
Each step must be classified by type, chosen from EXACTLY this set:
|
|
539
|
+
plan - high-level decomposition / design decisions
|
|
540
|
+
search - locate files, symbols, or information in the codebase
|
|
541
|
+
read - read & understand existing code
|
|
542
|
+
edit - write or modify code
|
|
543
|
+
command - run a shell/build/test command
|
|
544
|
+
review - critique code for correctness or bugs
|
|
545
|
+
reason - hard algorithmic or architectural reasoning
|
|
546
|
+
explain - explain results to the user
|
|
547
|
+
summarize - condense long content
|
|
548
|
+
chat - a simple conversational reply
|
|
549
|
+
|
|
550
|
+
Return ONLY minified JSON of the form:
|
|
551
|
+
{"steps":[{"type":"<type>","description":"...","estPromptTokens":<int>,"estCompletionTokens":<int>}]}
|
|
552
|
+
Use 3-8 steps for non-trivial work, fewer for simple requests. Estimate tokens realistically (prompts often 2000-15000, completions 200-3000).`;
|
|
553
|
+
function heuristicPlan(goal) {
|
|
554
|
+
const steps = [
|
|
555
|
+
{ id: 1, type: "plan", description: "Decompose the request", estPromptTokens: 2e3, estCompletionTokens: 600 },
|
|
556
|
+
{ id: 2, type: "search", description: "Locate relevant files", estPromptTokens: 3e3, estCompletionTokens: 400 },
|
|
557
|
+
{ id: 3, type: "read", description: "Read & understand code", estPromptTokens: 8e3, estCompletionTokens: 500 },
|
|
558
|
+
{ id: 4, type: "edit", description: "Implement the change", estPromptTokens: 9e3, estCompletionTokens: 1500 },
|
|
559
|
+
{ id: 5, type: "review", description: "Review the change", estPromptTokens: 6e3, estCompletionTokens: 800 }
|
|
560
|
+
];
|
|
561
|
+
return { goal, steps };
|
|
562
|
+
}
|
|
563
|
+
async function planRequest(goal, client2, planModel) {
|
|
564
|
+
const result = await client2.complete(
|
|
565
|
+
{
|
|
566
|
+
model: planModel.id,
|
|
567
|
+
messages: [
|
|
568
|
+
{ role: "system", content: PLAN_SYSTEM },
|
|
569
|
+
{ role: "user", content: goal }
|
|
570
|
+
],
|
|
571
|
+
temperature: 0,
|
|
572
|
+
maxTokens: 1200
|
|
573
|
+
},
|
|
574
|
+
planModel.pricing
|
|
575
|
+
);
|
|
576
|
+
const parsed = extractPlan(result.content);
|
|
577
|
+
if (!parsed) return heuristicPlan(goal);
|
|
578
|
+
return { goal, steps: parsed };
|
|
579
|
+
}
|
|
580
|
+
function extractPlan(text) {
|
|
581
|
+
const json = extractJson(text);
|
|
582
|
+
if (!json) return null;
|
|
583
|
+
try {
|
|
584
|
+
const obj = JSON.parse(json);
|
|
585
|
+
if (!Array.isArray(obj.steps)) return null;
|
|
586
|
+
const steps = obj.steps.map((s, i) => ({
|
|
587
|
+
id: i + 1,
|
|
588
|
+
type: coerceTaskType(s.type),
|
|
589
|
+
description: String(s.description ?? "").slice(0, 300) || "(step)",
|
|
590
|
+
estPromptTokens: clampInt(s.estPromptTokens, 500, 6e4, 4e3),
|
|
591
|
+
estCompletionTokens: clampInt(s.estCompletionTokens, 100, 8e3, 800)
|
|
592
|
+
}));
|
|
593
|
+
return steps.length ? steps : null;
|
|
594
|
+
} catch {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function coerceTaskType(v) {
|
|
599
|
+
const s = String(v).toLowerCase().trim();
|
|
600
|
+
return ALL_TASK_TYPES.includes(s) ? s : "edit";
|
|
601
|
+
}
|
|
602
|
+
function clampInt(v, min, max, fallback) {
|
|
603
|
+
const n = typeof v === "number" ? v : parseInt(String(v), 10);
|
|
604
|
+
if (!Number.isFinite(n)) return fallback;
|
|
605
|
+
return Math.min(max, Math.max(min, Math.round(n)));
|
|
606
|
+
}
|
|
607
|
+
function extractJson(text) {
|
|
608
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
609
|
+
const body = fence ? fence[1] : text;
|
|
610
|
+
const start = body.indexOf("{");
|
|
611
|
+
if (start === -1) return null;
|
|
612
|
+
let depth = 0;
|
|
613
|
+
let inString = false;
|
|
614
|
+
for (let i = start; i < body.length; i++) {
|
|
615
|
+
const ch = body[i];
|
|
616
|
+
if (inString) {
|
|
617
|
+
if (ch === "\\") {
|
|
618
|
+
i++;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (ch === '"') inString = false;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (ch === '"') inString = true;
|
|
625
|
+
else if (ch === "{") depth++;
|
|
626
|
+
else if (ch === "}") {
|
|
627
|
+
depth--;
|
|
628
|
+
if (depth === 0) return body.slice(start, i + 1);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/router/policy.ts
|
|
635
|
+
var TIER_RANK = { cheap: 0, standard: 1, frontier: 2 };
|
|
636
|
+
function tierAtLeast(tier, min) {
|
|
637
|
+
return TIER_RANK[tier] >= TIER_RANK[min];
|
|
638
|
+
}
|
|
639
|
+
function blendedPrice(m) {
|
|
640
|
+
return (m.pricing.promptUsdPerMTok * 3 + m.pricing.completionUsdPerMTok) / 4;
|
|
641
|
+
}
|
|
642
|
+
function valueScore(m) {
|
|
643
|
+
const price = Math.max(blendedPrice(m), 0.01);
|
|
644
|
+
const capability = TIER_RANK[m.tier] + 1;
|
|
645
|
+
return capability / price;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/models/strengths.ts
|
|
649
|
+
var STRENGTH_RULES = [
|
|
650
|
+
// Anthropic — Claude Code's home turf: coding, agentic tool use, instruction following.
|
|
651
|
+
{ pattern: /claude.*(opus)/, skills: { coding: 1.7, reasoning: 1.5, general: 1.5 } },
|
|
652
|
+
{ pattern: /claude.*(sonnet)/, skills: { coding: 1.65, reasoning: 1.4, general: 1.45, vision: 1.3 } },
|
|
653
|
+
{ pattern: /claude.*(haiku)/, skills: { coding: 1.4, speed: 1.55, general: 1.15 } },
|
|
654
|
+
// OpenAI
|
|
655
|
+
{ pattern: /\bo[1-4]\b|gpt-5.*(thinking|pro)|o[1-4]-(mini|pro)/, skills: { reasoning: 1.7, coding: 1.35 } },
|
|
656
|
+
{ pattern: /gpt-4o|gpt-4\.1|gpt-5/, skills: { coding: 1.45, reasoning: 1.3, general: 1.45, vision: 1.35 } },
|
|
657
|
+
{ pattern: /gpt-4o-mini|gpt-4\.1-mini|gpt-5-mini|gpt-5-nano/, skills: { speed: 1.4, general: 1.1 } },
|
|
658
|
+
// DeepSeek — strong cheap coding & reasoning.
|
|
659
|
+
{ pattern: /deepseek.*(r1|reasoner)/, skills: { reasoning: 1.75, coding: 1.4 } },
|
|
660
|
+
{ pattern: /deepseek.*(v3|chat|coder|v2)/, skills: { coding: 1.55, reasoning: 1.25 } },
|
|
661
|
+
// Qwen
|
|
662
|
+
{ pattern: /qwen.*coder/, skills: { coding: 1.65 } },
|
|
663
|
+
{ pattern: /qwen.*(thinking|qwq)/, skills: { reasoning: 1.55, coding: 1.2 } },
|
|
664
|
+
{ pattern: /qwen(3|2\.5)?[-.]?(max|235b|72b|plus)/, skills: { coding: 1.3, reasoning: 1.3, general: 1.25 } },
|
|
665
|
+
// Coding specialists
|
|
666
|
+
{ pattern: /codestral|codex|code-|coder|kimi/, skills: { coding: 1.55, reasoning: 1.2 } },
|
|
667
|
+
// Google Gemini — long-context retrieval & cheap throughput.
|
|
668
|
+
{ pattern: /gemini.*(flash|lite)/, skills: { speed: 1.6, retrieval: 1.55, general: 1.15 } },
|
|
669
|
+
{ pattern: /gemini.*pro/, skills: { retrieval: 1.65, reasoning: 1.4, coding: 1.25, vision: 1.35 } },
|
|
670
|
+
// xAI / Meta / Mistral
|
|
671
|
+
{ pattern: /grok-[3-9]/, skills: { reasoning: 1.4, coding: 1.3, general: 1.25 } },
|
|
672
|
+
{ pattern: /llama.*(405b|maverick|70b)/, skills: { general: 1.25, coding: 1.15 } },
|
|
673
|
+
{ pattern: /mistral-large|mixtral/, skills: { coding: 1.2, general: 1.2 } },
|
|
674
|
+
// Small/fast families — strong at cheap throughput, not hard tasks.
|
|
675
|
+
{ pattern: /ministral|gemma|phi|nemotron-(nano|mini)|-(1|2|3|4)b\b|mini|nano|lite|small/, skills: { speed: 1.45 } }
|
|
676
|
+
];
|
|
677
|
+
var TASK_SKILL = {
|
|
678
|
+
plan: "reasoning",
|
|
679
|
+
search: "retrieval",
|
|
680
|
+
read: "retrieval",
|
|
681
|
+
edit: "coding",
|
|
682
|
+
command: "speed",
|
|
683
|
+
review: "reasoning",
|
|
684
|
+
reason: "reasoning",
|
|
685
|
+
explain: "general",
|
|
686
|
+
summarize: "speed",
|
|
687
|
+
chat: "speed"
|
|
688
|
+
};
|
|
689
|
+
var TIER_BASE = { cheap: 1, standard: 1.4, frontier: 1.8 };
|
|
690
|
+
function skillBonus(m, skill) {
|
|
691
|
+
const s = `${m.id} ${m.name}`.toLowerCase();
|
|
692
|
+
let bonus = 1;
|
|
693
|
+
for (const rule of STRENGTH_RULES) {
|
|
694
|
+
if (rule.pattern.test(s)) {
|
|
695
|
+
const b = rule.skills[skill];
|
|
696
|
+
if (b && b > bonus) bonus = b;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return bonus;
|
|
700
|
+
}
|
|
701
|
+
function strengthFor(m, skill) {
|
|
702
|
+
return TIER_BASE[m.tier] * skillBonus(m, skill);
|
|
703
|
+
}
|
|
704
|
+
function taskStrength(m, taskType) {
|
|
705
|
+
return strengthFor(m, TASK_SKILL[taskType]);
|
|
706
|
+
}
|
|
707
|
+
var TASK_MIN_STRENGTH = {
|
|
708
|
+
edit: 1.4,
|
|
709
|
+
review: 1.5,
|
|
710
|
+
reason: 1.5,
|
|
711
|
+
plan: 1.2
|
|
712
|
+
};
|
|
713
|
+
var HEADLINE_SKILLS = ["coding", "reasoning", "retrieval", "speed"];
|
|
714
|
+
|
|
715
|
+
// src/router/router.ts
|
|
716
|
+
function projectCost(m, est) {
|
|
717
|
+
return est.promptTokens / 1e6 * m.pricing.promptUsdPerMTok + est.completionTokens / 1e6 * m.pricing.completionUsdPerMTok;
|
|
718
|
+
}
|
|
719
|
+
function taskValue(m, taskType) {
|
|
720
|
+
return taskStrength(m, taskType) / Math.max(blendedPrice(m), 0.01);
|
|
721
|
+
}
|
|
722
|
+
function candidatesFor(taskType, models, policy, est) {
|
|
723
|
+
const spec = TASK_SPECS[taskType];
|
|
724
|
+
const strengthFloor = TASK_MIN_STRENGTH[taskType] ?? 0;
|
|
725
|
+
return models.filter((m) => {
|
|
726
|
+
if (m.id === "openrouter/auto") return false;
|
|
727
|
+
const covers = tierAtLeast(m.tier, spec.minTier) || taskStrength(m, taskType) >= strengthFloor;
|
|
728
|
+
if (!covers) return false;
|
|
729
|
+
if (spec.needsTools && !m.capabilities.tools) return false;
|
|
730
|
+
if (policy.maxCostPerCallUsd != null && est) {
|
|
731
|
+
if (projectCost(m, est) > policy.maxCostPerCallUsd) return false;
|
|
732
|
+
}
|
|
733
|
+
return true;
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
function rank(models, policy, taskType) {
|
|
737
|
+
const sorted = [...models];
|
|
738
|
+
switch (policy.objective) {
|
|
739
|
+
case "cheapest":
|
|
740
|
+
sorted.sort((a, b) => blendedPrice(a) - blendedPrice(b));
|
|
741
|
+
break;
|
|
742
|
+
case "quality":
|
|
743
|
+
sorted.sort(
|
|
744
|
+
(a, b) => taskStrength(b, taskType) - taskStrength(a, taskType) || blendedPrice(b) - blendedPrice(a)
|
|
745
|
+
);
|
|
746
|
+
break;
|
|
747
|
+
case "value":
|
|
748
|
+
default:
|
|
749
|
+
sorted.sort((a, b) => taskValue(b, taskType) - taskValue(a, taskType));
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
return sorted;
|
|
753
|
+
}
|
|
754
|
+
function route(taskType, models, policy, est = { promptTokens: 4e3, completionTokens: 1e3 }) {
|
|
755
|
+
const pinId = policy.pinned?.[taskType];
|
|
756
|
+
if (pinId) {
|
|
757
|
+
const pinned = findModel(models, pinId);
|
|
758
|
+
if (pinned) {
|
|
759
|
+
return { model: pinned, reason: `pinned for ${taskType}`, estCostUsd: projectCost(pinned, est) };
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const cands = candidatesFor(taskType, models, policy, est);
|
|
763
|
+
if (!cands.length) return null;
|
|
764
|
+
const ranked = rank(cands, policy, taskType);
|
|
765
|
+
const chosen = ranked[0];
|
|
766
|
+
const skill = TASK_SKILL[taskType];
|
|
767
|
+
const reason = policy.objective === "cheapest" ? `cheapest model that covers ${skill}` : policy.objective === "quality" ? `strongest at ${skill}` : `best ${skill}-per-dollar`;
|
|
768
|
+
return { model: chosen, reason, estCostUsd: projectCost(chosen, est) };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// src/recommend/recommend.ts
|
|
772
|
+
var OBJECTIVES = [
|
|
773
|
+
{ objective: "cheapest", label: "Cheapest \u2014 minimize cost" },
|
|
774
|
+
{ objective: "value", label: "Best value \u2014 capability per dollar" },
|
|
775
|
+
{ objective: "quality", label: "Best quality \u2014 strongest per task" }
|
|
776
|
+
];
|
|
777
|
+
function estOf(step) {
|
|
778
|
+
return { promptTokens: step.estPromptTokens, completionTokens: step.estCompletionTokens };
|
|
779
|
+
}
|
|
780
|
+
function strategyFor(plan, models, objective, label) {
|
|
781
|
+
const policy = { objective };
|
|
782
|
+
const assignments = plan.steps.map((step) => {
|
|
783
|
+
const r = route(step.type, models, policy, estOf(step));
|
|
784
|
+
return r ? { step, model: r.model, estCostUsd: r.estCostUsd, reason: r.reason } : { step, model: null, estCostUsd: 0, reason: "no capable model" };
|
|
785
|
+
});
|
|
786
|
+
const totalCostUsd = assignments.reduce((s, a) => s + a.estCostUsd, 0);
|
|
787
|
+
const distinctModels = new Set(assignments.map((a) => a.model?.id).filter(Boolean)).size;
|
|
788
|
+
return { objective, label, assignments, totalCostUsd, distinctModels };
|
|
789
|
+
}
|
|
790
|
+
function bestValueBySkill(models) {
|
|
791
|
+
const out = {};
|
|
792
|
+
for (const skill of HEADLINE_SKILLS) {
|
|
793
|
+
out[skill] = models.filter((m) => m.id !== "openrouter/auto" && skillBonus(m, skill) > 1.05).sort(
|
|
794
|
+
(a, b) => strengthFor(b, skill) / Math.max(blendedPrice(b), 0.01) - strengthFor(a, skill) / Math.max(blendedPrice(a), 0.01)
|
|
795
|
+
).slice(0, 4);
|
|
796
|
+
}
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
function bestValueByTier(models) {
|
|
800
|
+
const tiers = ["cheap", "standard", "frontier"];
|
|
801
|
+
const out = {};
|
|
802
|
+
for (const t of tiers) {
|
|
803
|
+
out[t] = models.filter((m) => m.tier === t && m.id !== "openrouter/auto").sort((a, b) => valueScore(b) - valueScore(a)).slice(0, 5);
|
|
804
|
+
}
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
function singleModelCost(plan, model) {
|
|
808
|
+
let feasible = true;
|
|
809
|
+
let total = 0;
|
|
810
|
+
for (const step of plan.steps) {
|
|
811
|
+
const spec = TASK_SPECS[step.type];
|
|
812
|
+
if (!tierAtLeast(model.tier, spec.minTier)) feasible = false;
|
|
813
|
+
if (spec.needsTools && !model.capabilities.tools) feasible = false;
|
|
814
|
+
total += projectCost(model, estOf(step));
|
|
815
|
+
}
|
|
816
|
+
return { model, totalCostUsd: total, feasible };
|
|
817
|
+
}
|
|
818
|
+
function buildRecommendation(plan, models) {
|
|
819
|
+
const strategies = OBJECTIVES.map((o) => strategyFor(plan, models, o.objective, o.label));
|
|
820
|
+
const byTier = bestValueByTier(models);
|
|
821
|
+
const picks = ["cheap", "standard", "frontier"].map((t) => byTier[t][0]).filter(Boolean);
|
|
822
|
+
const singleModelBaselines = picks.map((m) => singleModelCost(plan, m));
|
|
823
|
+
const valueStrategy = strategies.find((s) => s.objective === "value");
|
|
824
|
+
const qualityStrategy = strategies.find((s) => s.objective === "quality");
|
|
825
|
+
const savingsPct = qualityStrategy.totalCostUsd > 0 ? (1 - valueStrategy.totalCostUsd / qualityStrategy.totalCostUsd) * 100 : 0;
|
|
826
|
+
return {
|
|
827
|
+
plan,
|
|
828
|
+
strategies,
|
|
829
|
+
bestValueByTier: byTier,
|
|
830
|
+
bestValueBySkill: bestValueBySkill(models),
|
|
831
|
+
singleModelBaselines,
|
|
832
|
+
savingsPct
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function renderRecommendation(rec) {
|
|
836
|
+
const out = [];
|
|
837
|
+
out.push(c.bold(`Plan for: ${c.cyan(rec.plan.goal)}`));
|
|
838
|
+
out.push(
|
|
839
|
+
table(
|
|
840
|
+
["#", "Task", "Type", "Needs", "Est tok (in/out)"],
|
|
841
|
+
rec.plan.steps.map((s) => [
|
|
842
|
+
String(s.id),
|
|
843
|
+
s.description,
|
|
844
|
+
tierColor(TASK_SPECS[s.type].minTier, s.type),
|
|
845
|
+
TASK_SKILL[s.type],
|
|
846
|
+
`${s.estPromptTokens}/${s.estCompletionTokens}`
|
|
847
|
+
])
|
|
848
|
+
)
|
|
849
|
+
);
|
|
850
|
+
out.push("");
|
|
851
|
+
for (const strat of rec.strategies) {
|
|
852
|
+
out.push(c.bold(strat.label) + c.dim(` (${strat.distinctModels} model(s), est ${usd(strat.totalCostUsd)})`));
|
|
853
|
+
out.push(
|
|
854
|
+
table(
|
|
855
|
+
["Step", "Model", "Tier", "Est cost"],
|
|
856
|
+
strat.assignments.map((a) => [
|
|
857
|
+
`${a.step.id} ${a.step.type}`,
|
|
858
|
+
a.model ? a.model.id : c.red("(none)"),
|
|
859
|
+
a.model ? tierColor(a.model.tier) : "-",
|
|
860
|
+
usd(a.estCostUsd)
|
|
861
|
+
])
|
|
862
|
+
)
|
|
863
|
+
);
|
|
864
|
+
out.push("");
|
|
865
|
+
}
|
|
866
|
+
out.push(c.bold("Best value by skill") + c.dim(" (strongest-per-dollar for each kind of work)"));
|
|
867
|
+
for (const skill of HEADLINE_SKILLS) {
|
|
868
|
+
const list = rec.bestValueBySkill[skill];
|
|
869
|
+
if (!list || !list.length) continue;
|
|
870
|
+
out.push(
|
|
871
|
+
" " + c.cyan(skill.padEnd(10)) + list.slice(0, 3).map((m) => `${m.id} ${c.dim(perMTok(blendedPrice(m)))}`).join(c.dim(" \xB7 "))
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
out.push("");
|
|
875
|
+
out.push(c.bold("Best-value models by tier") + c.dim(" (capability per dollar)"));
|
|
876
|
+
for (const tier of ["cheap", "standard", "frontier"]) {
|
|
877
|
+
const list = rec.bestValueByTier[tier];
|
|
878
|
+
if (!list.length) continue;
|
|
879
|
+
out.push(
|
|
880
|
+
" " + tierColor(tier, tier.toUpperCase().padEnd(9)) + list.slice(0, 3).map((m) => `${m.id} ${c.dim(perMTok(blendedPrice(m)))}`).join(c.dim(" \xB7 "))
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
out.push("");
|
|
884
|
+
out.push(c.bold("Single-model baselines (whole plan on one model)"));
|
|
885
|
+
out.push(
|
|
886
|
+
table(
|
|
887
|
+
["Model", "Tier", "Feasible?", "Est cost"],
|
|
888
|
+
rec.singleModelBaselines.map((b) => [
|
|
889
|
+
b.model.id,
|
|
890
|
+
tierColor(b.model.tier),
|
|
891
|
+
b.feasible ? c.green("yes") : c.yellow("partial"),
|
|
892
|
+
usd(b.totalCostUsd)
|
|
893
|
+
])
|
|
894
|
+
)
|
|
895
|
+
);
|
|
896
|
+
out.push("");
|
|
897
|
+
if (rec.savingsPct > 0) {
|
|
898
|
+
out.push(
|
|
899
|
+
c.green(
|
|
900
|
+
`\u2192 Best-value routing costs ~${rec.savingsPct.toFixed(
|
|
901
|
+
0
|
|
902
|
+
)}% less than running every step on the strongest model.`
|
|
903
|
+
)
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
out.push(
|
|
907
|
+
c.dim(
|
|
908
|
+
"Estimates use a heuristic plan + token guesses; actuals are logged per call and shown in `poly usage`."
|
|
909
|
+
)
|
|
910
|
+
);
|
|
911
|
+
return out.join("\n");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// src/usage/db.ts
|
|
915
|
+
import { DatabaseSync } from "node:sqlite";
|
|
916
|
+
var db = null;
|
|
917
|
+
function getDb() {
|
|
918
|
+
if (db) return db;
|
|
919
|
+
ensureConfigDir();
|
|
920
|
+
db = new DatabaseSync(dbFilePath());
|
|
921
|
+
db.exec(`
|
|
922
|
+
CREATE TABLE IF NOT EXISTS usage_log (
|
|
923
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
924
|
+
ts INTEGER NOT NULL,
|
|
925
|
+
date TEXT NOT NULL,
|
|
926
|
+
provider TEXT NOT NULL,
|
|
927
|
+
model TEXT NOT NULL,
|
|
928
|
+
task_type TEXT NOT NULL,
|
|
929
|
+
prompt_tokens INTEGER NOT NULL,
|
|
930
|
+
completion_tokens INTEGER NOT NULL,
|
|
931
|
+
total_tokens INTEGER NOT NULL,
|
|
932
|
+
cost_usd REAL NOT NULL,
|
|
933
|
+
session_id TEXT,
|
|
934
|
+
synced INTEGER NOT NULL DEFAULT 0
|
|
935
|
+
);
|
|
936
|
+
CREATE INDEX IF NOT EXISTS idx_usage_date ON usage_log(date);
|
|
937
|
+
CREATE INDEX IF NOT EXISTS idx_usage_model ON usage_log(model);
|
|
938
|
+
`);
|
|
939
|
+
return db;
|
|
940
|
+
}
|
|
941
|
+
function recordUsage(e) {
|
|
942
|
+
const stmt = getDb().prepare(`
|
|
943
|
+
INSERT INTO usage_log
|
|
944
|
+
(ts, date, provider, model, task_type, prompt_tokens, completion_tokens, total_tokens, cost_usd, session_id)
|
|
945
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
946
|
+
`);
|
|
947
|
+
stmt.run(
|
|
948
|
+
e.ts,
|
|
949
|
+
e.date,
|
|
950
|
+
e.provider,
|
|
951
|
+
e.model,
|
|
952
|
+
e.taskType,
|
|
953
|
+
e.promptTokens,
|
|
954
|
+
e.completionTokens,
|
|
955
|
+
e.totalTokens,
|
|
956
|
+
e.costUsd,
|
|
957
|
+
e.sessionId ?? null
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
function reportByDateModel(filter = {}) {
|
|
961
|
+
const where = [];
|
|
962
|
+
const params = [];
|
|
963
|
+
if (filter.since) {
|
|
964
|
+
where.push("date >= ?");
|
|
965
|
+
params.push(filter.since);
|
|
966
|
+
}
|
|
967
|
+
if (filter.until) {
|
|
968
|
+
where.push("date <= ?");
|
|
969
|
+
params.push(filter.until);
|
|
970
|
+
}
|
|
971
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
972
|
+
const rows = getDb().prepare(
|
|
973
|
+
`SELECT date, model, provider,
|
|
974
|
+
COUNT(*) AS calls,
|
|
975
|
+
SUM(prompt_tokens) AS promptTokens,
|
|
976
|
+
SUM(completion_tokens) AS completionTokens,
|
|
977
|
+
SUM(total_tokens) AS totalTokens,
|
|
978
|
+
SUM(cost_usd) AS costUsd
|
|
979
|
+
FROM usage_log
|
|
980
|
+
${whereSql}
|
|
981
|
+
GROUP BY date, model
|
|
982
|
+
ORDER BY date DESC, costUsd DESC`
|
|
983
|
+
).all(...params);
|
|
984
|
+
return rows.map((r) => ({
|
|
985
|
+
date: String(r.date),
|
|
986
|
+
model: String(r.model),
|
|
987
|
+
provider: String(r.provider),
|
|
988
|
+
calls: Number(r.calls),
|
|
989
|
+
promptTokens: Number(r.promptTokens),
|
|
990
|
+
completionTokens: Number(r.completionTokens),
|
|
991
|
+
totalTokens: Number(r.totalTokens),
|
|
992
|
+
costUsd: Number(r.costUsd)
|
|
993
|
+
}));
|
|
994
|
+
}
|
|
995
|
+
function totals(filter = {}) {
|
|
996
|
+
const rows = reportByDateModel(filter);
|
|
997
|
+
return rows.reduce(
|
|
998
|
+
(acc, r) => ({
|
|
999
|
+
calls: acc.calls + r.calls,
|
|
1000
|
+
totalTokens: acc.totalTokens + r.totalTokens,
|
|
1001
|
+
costUsd: acc.costUsd + r.costUsd
|
|
1002
|
+
}),
|
|
1003
|
+
{ calls: 0, totalTokens: 0, costUsd: 0 }
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
function unsyncedRows() {
|
|
1007
|
+
const rows = getDb().prepare(`SELECT * FROM usage_log WHERE synced = 0 ORDER BY id ASC LIMIT 500`).all();
|
|
1008
|
+
return rows.map((r) => ({
|
|
1009
|
+
id: Number(r.id),
|
|
1010
|
+
ts: Number(r.ts),
|
|
1011
|
+
date: String(r.date),
|
|
1012
|
+
provider: String(r.provider),
|
|
1013
|
+
model: String(r.model),
|
|
1014
|
+
taskType: String(r.task_type),
|
|
1015
|
+
promptTokens: Number(r.prompt_tokens),
|
|
1016
|
+
completionTokens: Number(r.completion_tokens),
|
|
1017
|
+
totalTokens: Number(r.total_tokens),
|
|
1018
|
+
costUsd: Number(r.cost_usd),
|
|
1019
|
+
sessionId: r.session_id ? String(r.session_id) : void 0
|
|
1020
|
+
}));
|
|
1021
|
+
}
|
|
1022
|
+
function markSynced(ids) {
|
|
1023
|
+
if (!ids.length) return;
|
|
1024
|
+
const stmt = getDb().prepare(`UPDATE usage_log SET synced = 1 WHERE id = ?`);
|
|
1025
|
+
for (const id of ids) stmt.run(id);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/usage/report.ts
|
|
1029
|
+
function renderUsageReport(filter = {}) {
|
|
1030
|
+
const rows = reportByDateModel(filter);
|
|
1031
|
+
if (!rows.length) {
|
|
1032
|
+
return c.dim('No usage recorded yet. Run `poly run "<task>"` to start tracking.');
|
|
1033
|
+
}
|
|
1034
|
+
const body = table(
|
|
1035
|
+
["Date", "Model", "Calls", "Prompt", "Compl.", "Cost"],
|
|
1036
|
+
rows.map((r) => [
|
|
1037
|
+
r.date,
|
|
1038
|
+
r.model,
|
|
1039
|
+
String(r.calls),
|
|
1040
|
+
tokens(r.promptTokens),
|
|
1041
|
+
tokens(r.completionTokens),
|
|
1042
|
+
usd(r.costUsd)
|
|
1043
|
+
])
|
|
1044
|
+
);
|
|
1045
|
+
const t = totals(filter);
|
|
1046
|
+
const byModel = /* @__PURE__ */ new Map();
|
|
1047
|
+
for (const r of rows) {
|
|
1048
|
+
const cur = byModel.get(r.model) ?? { cost: 0, tokens: 0, calls: 0 };
|
|
1049
|
+
cur.cost += r.costUsd;
|
|
1050
|
+
cur.tokens += r.totalTokens;
|
|
1051
|
+
cur.calls += r.calls;
|
|
1052
|
+
byModel.set(r.model, cur);
|
|
1053
|
+
}
|
|
1054
|
+
const modelRollup = table(
|
|
1055
|
+
["Model", "Calls", "Tokens", "Cost"],
|
|
1056
|
+
[...byModel.entries()].sort((a, b) => b[1].cost - a[1].cost).map(([model, v]) => [model, String(v.calls), tokens(v.tokens), usd(v.cost)])
|
|
1057
|
+
);
|
|
1058
|
+
return [
|
|
1059
|
+
c.bold("Usage by date + model"),
|
|
1060
|
+
body,
|
|
1061
|
+
"",
|
|
1062
|
+
c.bold("Totals by model"),
|
|
1063
|
+
modelRollup,
|
|
1064
|
+
"",
|
|
1065
|
+
`${c.bold("TOTAL")} ${t.calls} calls \xB7 ${tokens(t.totalTokens)} tokens \xB7 ${c.green(usd(t.costUsd))}`
|
|
1066
|
+
].join("\n");
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// src/usage/firestoreSync.ts
|
|
1070
|
+
async function syncUsage(config) {
|
|
1071
|
+
if (!config.firestore.enabled) {
|
|
1072
|
+
return { synced: 0, message: "Firestore sync is disabled (enable with `poly config firestore on`)." };
|
|
1073
|
+
}
|
|
1074
|
+
let appMod, fsMod;
|
|
1075
|
+
try {
|
|
1076
|
+
appMod = await import("firebase-admin/app");
|
|
1077
|
+
fsMod = await import("firebase-admin/firestore");
|
|
1078
|
+
} catch {
|
|
1079
|
+
return {
|
|
1080
|
+
synced: 0,
|
|
1081
|
+
message: "firebase-admin is not installed. Run `npm install firebase-admin` to enable sync."
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
const { initializeApp, getApps, cert } = appMod;
|
|
1085
|
+
if (getApps().length === 0) {
|
|
1086
|
+
const saJson = process.env.FIREBASE_SERVICE_ACCOUNT_KEY;
|
|
1087
|
+
if (saJson) {
|
|
1088
|
+
try {
|
|
1089
|
+
initializeApp({ credential: cert(JSON.parse(saJson)) });
|
|
1090
|
+
} catch {
|
|
1091
|
+
initializeApp({ projectId: config.firestore.projectId });
|
|
1092
|
+
}
|
|
1093
|
+
} else {
|
|
1094
|
+
initializeApp({ projectId: config.firestore.projectId });
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
const fdb = fsMod.getFirestore();
|
|
1098
|
+
const rows = unsyncedRows();
|
|
1099
|
+
if (!rows.length) return { synced: 0, message: "Nothing to sync \u2014 all rows already pushed." };
|
|
1100
|
+
const batch = fdb.batch();
|
|
1101
|
+
const col = fdb.collection(config.firestore.collection);
|
|
1102
|
+
for (const r of rows) {
|
|
1103
|
+
const ref = col.doc(`${r.date}__${r.id}`);
|
|
1104
|
+
batch.set(ref, {
|
|
1105
|
+
ts: r.ts,
|
|
1106
|
+
date: r.date,
|
|
1107
|
+
provider: r.provider,
|
|
1108
|
+
model: r.model,
|
|
1109
|
+
taskType: r.taskType,
|
|
1110
|
+
promptTokens: r.promptTokens,
|
|
1111
|
+
completionTokens: r.completionTokens,
|
|
1112
|
+
totalTokens: r.totalTokens,
|
|
1113
|
+
costUsd: r.costUsd,
|
|
1114
|
+
sessionId: r.sessionId ?? null
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
await batch.commit();
|
|
1118
|
+
markSynced(rows.map((r) => r.id));
|
|
1119
|
+
return { synced: rows.length, message: `Synced ${rows.length} rows to ${config.firestore.collection}.` };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// src/tui/App.tsx
|
|
1123
|
+
import { useState, useEffect, useCallback } from "react";
|
|
1124
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
1125
|
+
import TextInput from "ink-text-input";
|
|
1126
|
+
import Spinner from "ink-spinner";
|
|
1127
|
+
|
|
1128
|
+
// src/agent/tools.ts
|
|
1129
|
+
import fs4 from "node:fs";
|
|
1130
|
+
import path2 from "node:path";
|
|
1131
|
+
import { execSync } from "node:child_process";
|
|
1132
|
+
var TOOL_SCHEMAS = [
|
|
1133
|
+
{
|
|
1134
|
+
type: "function",
|
|
1135
|
+
function: {
|
|
1136
|
+
name: "read_file",
|
|
1137
|
+
description: "Read a UTF-8 text file relative to the working directory.",
|
|
1138
|
+
parameters: {
|
|
1139
|
+
type: "object",
|
|
1140
|
+
properties: { path: { type: "string", description: "File path" } },
|
|
1141
|
+
required: ["path"]
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
type: "function",
|
|
1147
|
+
function: {
|
|
1148
|
+
name: "write_file",
|
|
1149
|
+
description: "Create or overwrite a UTF-8 text file. Creates parent directories as needed.",
|
|
1150
|
+
parameters: {
|
|
1151
|
+
type: "object",
|
|
1152
|
+
properties: {
|
|
1153
|
+
path: { type: "string" },
|
|
1154
|
+
content: { type: "string" }
|
|
1155
|
+
},
|
|
1156
|
+
required: ["path", "content"]
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
},
|
|
1160
|
+
{
|
|
1161
|
+
type: "function",
|
|
1162
|
+
function: {
|
|
1163
|
+
name: "list_dir",
|
|
1164
|
+
description: "List the entries of a directory.",
|
|
1165
|
+
parameters: {
|
|
1166
|
+
type: "object",
|
|
1167
|
+
properties: { path: { type: "string", description: "Directory path (default '.')" } }
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
type: "function",
|
|
1173
|
+
function: {
|
|
1174
|
+
name: "run_command",
|
|
1175
|
+
description: "Run a shell command in the working directory and return combined stdout/stderr.",
|
|
1176
|
+
parameters: {
|
|
1177
|
+
type: "object",
|
|
1178
|
+
properties: { command: { type: "string" } },
|
|
1179
|
+
required: ["command"]
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
},
|
|
1183
|
+
{
|
|
1184
|
+
type: "function",
|
|
1185
|
+
function: {
|
|
1186
|
+
name: "finish",
|
|
1187
|
+
description: "Signal that the task is complete with a short summary for the user.",
|
|
1188
|
+
parameters: {
|
|
1189
|
+
type: "object",
|
|
1190
|
+
properties: { summary: { type: "string" } },
|
|
1191
|
+
required: ["summary"]
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
];
|
|
1196
|
+
var MAX_OUTPUT = 8e3;
|
|
1197
|
+
function clip(s) {
|
|
1198
|
+
return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + `
|
|
1199
|
+
\u2026(truncated, ${s.length} chars)` : s;
|
|
1200
|
+
}
|
|
1201
|
+
function redactSecrets(s) {
|
|
1202
|
+
return s.replace(/sk-or-[A-Za-z0-9._-]+/g, "sk-or-***REDACTED***").replace(/"private_key"\s*:\s*"[^"]*"/g, '"private_key":"***REDACTED***"');
|
|
1203
|
+
}
|
|
1204
|
+
var SECRET_ENV = ["OPENROUTER_API_KEY", "FIREBASE_SERVICE_ACCOUNT_KEY", "GOOGLE_APPLICATION_CREDENTIALS"];
|
|
1205
|
+
function scrubbedEnv() {
|
|
1206
|
+
const env = { ...process.env };
|
|
1207
|
+
for (const k of SECRET_ENV) delete env[k];
|
|
1208
|
+
return env;
|
|
1209
|
+
}
|
|
1210
|
+
function resolve(ctx, p) {
|
|
1211
|
+
const root = path2.resolve(ctx.cwd);
|
|
1212
|
+
const abs = path2.resolve(root, p);
|
|
1213
|
+
const rel = path2.relative(root, abs);
|
|
1214
|
+
if (rel === "") return abs;
|
|
1215
|
+
if (rel.startsWith("..") || path2.isAbsolute(rel)) {
|
|
1216
|
+
throw new Error(`path escapes the working directory: ${p}`);
|
|
1217
|
+
}
|
|
1218
|
+
return abs;
|
|
1219
|
+
}
|
|
1220
|
+
function executeTool(name, argsJson, ctx) {
|
|
1221
|
+
let args = {};
|
|
1222
|
+
try {
|
|
1223
|
+
args = argsJson ? JSON.parse(argsJson) : {};
|
|
1224
|
+
} catch {
|
|
1225
|
+
return { result: `Error: could not parse arguments: ${argsJson}` };
|
|
1226
|
+
}
|
|
1227
|
+
try {
|
|
1228
|
+
switch (name) {
|
|
1229
|
+
case "read_file": {
|
|
1230
|
+
const file = resolve(ctx, String(args.path));
|
|
1231
|
+
if (!fs4.existsSync(file)) return { result: `Error: file not found: ${args.path}` };
|
|
1232
|
+
return { result: clip(fs4.readFileSync(file, "utf8")) };
|
|
1233
|
+
}
|
|
1234
|
+
case "list_dir": {
|
|
1235
|
+
const dir = resolve(ctx, String(args.path ?? "."));
|
|
1236
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
1237
|
+
return {
|
|
1238
|
+
result: clip(
|
|
1239
|
+
entries.map((e) => e.isDirectory() ? e.name + "/" : e.name).sort().join("\n")
|
|
1240
|
+
)
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
case "write_file": {
|
|
1244
|
+
if (!ctx.allowWrite) return { result: "Denied: write_file is disabled (read-only mode)." };
|
|
1245
|
+
const file = resolve(ctx, String(args.path));
|
|
1246
|
+
fs4.mkdirSync(path2.dirname(file), { recursive: true });
|
|
1247
|
+
fs4.writeFileSync(file, String(args.content ?? ""), "utf8");
|
|
1248
|
+
return { result: `Wrote ${args.path} (${String(args.content ?? "").length} bytes).` };
|
|
1249
|
+
}
|
|
1250
|
+
case "run_command": {
|
|
1251
|
+
if (!ctx.allowCommands) return { result: "Denied: run_command is disabled." };
|
|
1252
|
+
const out = execSync(String(args.command), {
|
|
1253
|
+
cwd: ctx.cwd,
|
|
1254
|
+
encoding: "utf8",
|
|
1255
|
+
env: scrubbedEnv(),
|
|
1256
|
+
// never expose API key / service-account JSON to the child
|
|
1257
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1258
|
+
timeout: 6e4,
|
|
1259
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1260
|
+
});
|
|
1261
|
+
return { result: clip(redactSecrets(out || "(no output)")) };
|
|
1262
|
+
}
|
|
1263
|
+
case "finish":
|
|
1264
|
+
return { result: "ok", finishSummary: String(args.summary ?? "Done.") };
|
|
1265
|
+
default:
|
|
1266
|
+
return { result: `Error: unknown tool ${name}` };
|
|
1267
|
+
}
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
const stdout = err?.stdout?.toString?.() ?? "";
|
|
1270
|
+
const stderr = err?.stderr?.toString?.() ?? "";
|
|
1271
|
+
return { result: clip(redactSecrets(`Error: ${err?.message ?? String(err)}
|
|
1272
|
+
${stdout}
|
|
1273
|
+
${stderr}`)) };
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// src/usage/logger.ts
|
|
1278
|
+
function localDate(d = /* @__PURE__ */ new Date()) {
|
|
1279
|
+
const y = d.getFullYear();
|
|
1280
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
1281
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
1282
|
+
return `${y}-${m}-${day}`;
|
|
1283
|
+
}
|
|
1284
|
+
function providerOf(modelId) {
|
|
1285
|
+
return modelId.split("/")[0] ?? "unknown";
|
|
1286
|
+
}
|
|
1287
|
+
function logCompletion(result, taskType, sessionId) {
|
|
1288
|
+
const now = /* @__PURE__ */ new Date();
|
|
1289
|
+
const entry = {
|
|
1290
|
+
ts: now.getTime(),
|
|
1291
|
+
date: localDate(now),
|
|
1292
|
+
provider: providerOf(result.model),
|
|
1293
|
+
model: result.model,
|
|
1294
|
+
taskType,
|
|
1295
|
+
promptTokens: result.usage.promptTokens,
|
|
1296
|
+
completionTokens: result.usage.completionTokens,
|
|
1297
|
+
totalTokens: result.usage.totalTokens,
|
|
1298
|
+
costUsd: result.costUsd,
|
|
1299
|
+
sessionId
|
|
1300
|
+
};
|
|
1301
|
+
recordUsage(entry);
|
|
1302
|
+
return entry;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// src/agent/loop.ts
|
|
1306
|
+
var MAX_ITERS_PER_STEP = 6;
|
|
1307
|
+
async function runAgent(goal, deps, emit) {
|
|
1308
|
+
const { client: client2, models, policy, sessionId, cwd } = deps;
|
|
1309
|
+
let totalCostUsd = 0;
|
|
1310
|
+
let totalTokens = 0;
|
|
1311
|
+
let calls = 0;
|
|
1312
|
+
const planRoute = route("plan", models, policy);
|
|
1313
|
+
let plan;
|
|
1314
|
+
if (planRoute) {
|
|
1315
|
+
try {
|
|
1316
|
+
plan = await planRequest(goal, client2, planRoute.model);
|
|
1317
|
+
} catch {
|
|
1318
|
+
plan = heuristicPlan(goal);
|
|
1319
|
+
}
|
|
1320
|
+
} else {
|
|
1321
|
+
plan = heuristicPlan(goal);
|
|
1322
|
+
}
|
|
1323
|
+
emit({ type: "plan", plan, planModel: planRoute?.model.id ?? "heuristic" });
|
|
1324
|
+
const toolCtx = {
|
|
1325
|
+
cwd,
|
|
1326
|
+
allowWrite: deps.allowWrite,
|
|
1327
|
+
allowCommands: deps.allowCommands
|
|
1328
|
+
};
|
|
1329
|
+
const priorSummaries = [];
|
|
1330
|
+
for (const step of plan.steps) {
|
|
1331
|
+
const r = route(step.type, models, policy, {
|
|
1332
|
+
promptTokens: step.estPromptTokens,
|
|
1333
|
+
completionTokens: step.estCompletionTokens
|
|
1334
|
+
});
|
|
1335
|
+
if (!r) {
|
|
1336
|
+
emit({ type: "error", message: `No capable model for step ${step.id} (${step.type}).` });
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
const model = r.model;
|
|
1340
|
+
emit({ type: "step-start", step, model, estCostUsd: r.estCostUsd });
|
|
1341
|
+
const useTools = model.capabilities.tools;
|
|
1342
|
+
const messages = [
|
|
1343
|
+
{ role: "system", content: stepSystemPrompt(goal, step, priorSummaries, useTools) },
|
|
1344
|
+
{ role: "user", content: step.description }
|
|
1345
|
+
];
|
|
1346
|
+
let summary = "";
|
|
1347
|
+
for (let iter = 0; iter < MAX_ITERS_PER_STEP; iter++) {
|
|
1348
|
+
const gen = client2.stream(
|
|
1349
|
+
{
|
|
1350
|
+
model: model.id,
|
|
1351
|
+
messages,
|
|
1352
|
+
tools: useTools ? TOOL_SCHEMAS : void 0,
|
|
1353
|
+
temperature: 0.2,
|
|
1354
|
+
maxTokens: 2e3
|
|
1355
|
+
},
|
|
1356
|
+
model.pricing
|
|
1357
|
+
);
|
|
1358
|
+
let next = await gen.next();
|
|
1359
|
+
while (!next.done) {
|
|
1360
|
+
emit({ type: "text", delta: next.value });
|
|
1361
|
+
next = await gen.next();
|
|
1362
|
+
}
|
|
1363
|
+
const result = next.value;
|
|
1364
|
+
const entry = logCompletion(result, step.type, sessionId);
|
|
1365
|
+
emit({ type: "usage", entry });
|
|
1366
|
+
totalCostUsd += entry.costUsd;
|
|
1367
|
+
totalTokens += entry.totalTokens;
|
|
1368
|
+
calls++;
|
|
1369
|
+
if (result.toolCalls.length && useTools) {
|
|
1370
|
+
messages.push({ role: "assistant", content: result.content, tool_calls: result.toolCalls });
|
|
1371
|
+
let finished = false;
|
|
1372
|
+
for (const tc of result.toolCalls) {
|
|
1373
|
+
emit({ type: "tool-call", name: tc.function.name, args: tc.function.arguments });
|
|
1374
|
+
const outcome = executeTool(tc.function.name, tc.function.arguments, toolCtx);
|
|
1375
|
+
emit({ type: "tool-result", name: tc.function.name, result: outcome.result });
|
|
1376
|
+
messages.push({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: outcome.result });
|
|
1377
|
+
if (outcome.finishSummary != null) {
|
|
1378
|
+
summary = outcome.finishSummary;
|
|
1379
|
+
finished = true;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
if (finished) break;
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
summary = result.content || summary;
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
if (!summary) summary = "(no summary)";
|
|
1389
|
+
priorSummaries.push(`Step ${step.id} (${step.type}): ${summary}`);
|
|
1390
|
+
emit({ type: "step-end", step, summary });
|
|
1391
|
+
}
|
|
1392
|
+
emit({ type: "done", totalCostUsd, totalTokens, calls });
|
|
1393
|
+
return { totalCostUsd, totalTokens, calls };
|
|
1394
|
+
}
|
|
1395
|
+
function stepSystemPrompt(goal, step, priorSummaries, useTools) {
|
|
1396
|
+
const context = priorSummaries.length ? `
|
|
1397
|
+
|
|
1398
|
+
What previous steps accomplished:
|
|
1399
|
+
${priorSummaries.join("\n")}` : "";
|
|
1400
|
+
const toolNote = useTools ? `
|
|
1401
|
+
You may use the provided tools (read_file, write_file, list_dir, run_command). Call the \`finish\` tool with a one-line summary when this step's objective is met.` : `
|
|
1402
|
+
Return a concise result for this step. Do not ask the user questions.`;
|
|
1403
|
+
return `You are the "${step.type}" stage of an autonomous coding agent.
|
|
1404
|
+
Overall goal: ${goal}
|
|
1405
|
+
Your current step: ${step.description}${context}${toolNote}
|
|
1406
|
+
Be efficient \u2014 you were selected as the cheapest capable model for this step.`;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// src/tui/App.tsx
|
|
1410
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
1411
|
+
function App(props) {
|
|
1412
|
+
const { exit } = useApp();
|
|
1413
|
+
const [phase, setPhase] = useState(props.initialGoal ? "preview" : "input");
|
|
1414
|
+
const [goal, setGoal] = useState(props.initialGoal ?? "");
|
|
1415
|
+
const [draft, setDraft] = useState("");
|
|
1416
|
+
const [rec, setRec] = useState(null);
|
|
1417
|
+
const [log, setLog] = useState([]);
|
|
1418
|
+
const [cost, setCost] = useState(0);
|
|
1419
|
+
const [tok, setTok] = useState(0);
|
|
1420
|
+
const [calls, setCalls] = useState(0);
|
|
1421
|
+
const push = useCallback((text, color) => {
|
|
1422
|
+
setLog((l) => [...l, { key: l.length, text, color }]);
|
|
1423
|
+
}, []);
|
|
1424
|
+
useEffect(() => {
|
|
1425
|
+
if (phase === "preview" && goal) {
|
|
1426
|
+
setRec(buildRecommendation(heuristicPlan(goal), props.models));
|
|
1427
|
+
}
|
|
1428
|
+
}, [phase, goal, props.models]);
|
|
1429
|
+
const start = useCallback(async () => {
|
|
1430
|
+
setPhase("running");
|
|
1431
|
+
const deps = {
|
|
1432
|
+
client: props.client,
|
|
1433
|
+
models: props.models,
|
|
1434
|
+
policy: props.policy,
|
|
1435
|
+
sessionId: props.sessionId,
|
|
1436
|
+
cwd: props.cwd,
|
|
1437
|
+
allowWrite: props.allowWrite,
|
|
1438
|
+
allowCommands: props.allowCommands
|
|
1439
|
+
};
|
|
1440
|
+
let textBuf = "";
|
|
1441
|
+
const flush = () => {
|
|
1442
|
+
if (textBuf.trim()) push(textBuf.trim(), "white");
|
|
1443
|
+
textBuf = "";
|
|
1444
|
+
};
|
|
1445
|
+
const emit = (e) => {
|
|
1446
|
+
switch (e.type) {
|
|
1447
|
+
case "plan":
|
|
1448
|
+
push(`\u{1F4CB} Plan (${e.plan.steps.length} steps) \xB7 planner: ${e.planModel}`, "cyan");
|
|
1449
|
+
break;
|
|
1450
|
+
case "step-start":
|
|
1451
|
+
flush();
|
|
1452
|
+
push(`\u25B6 Step ${e.step.id} [${e.step.type}] \u2192 ${e.model.id} ~${usd(e.estCostUsd)}`, "yellow");
|
|
1453
|
+
break;
|
|
1454
|
+
case "text":
|
|
1455
|
+
textBuf += e.delta;
|
|
1456
|
+
break;
|
|
1457
|
+
case "tool-call":
|
|
1458
|
+
flush();
|
|
1459
|
+
push(` \u{1F527} ${e.name}(${truncate2(e.args, 80)})`, "magenta");
|
|
1460
|
+
break;
|
|
1461
|
+
case "tool-result":
|
|
1462
|
+
push(` \u21B3 ${truncate2(e.result.replace(/\n/g, " "), 100)}`, "gray");
|
|
1463
|
+
break;
|
|
1464
|
+
case "usage":
|
|
1465
|
+
setCost((c2) => c2 + e.entry.costUsd);
|
|
1466
|
+
setTok((t) => t + e.entry.totalTokens);
|
|
1467
|
+
setCalls((n) => n + 1);
|
|
1468
|
+
break;
|
|
1469
|
+
case "step-end":
|
|
1470
|
+
flush();
|
|
1471
|
+
push(` \u2713 ${truncate2(e.summary.replace(/\n/g, " "), 120)}`, "green");
|
|
1472
|
+
break;
|
|
1473
|
+
case "error":
|
|
1474
|
+
flush();
|
|
1475
|
+
push(` \u26A0 ${e.message}`, "red");
|
|
1476
|
+
break;
|
|
1477
|
+
case "done":
|
|
1478
|
+
flush();
|
|
1479
|
+
break;
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
try {
|
|
1483
|
+
await runAgent(goal, deps, emit);
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
push(`Fatal: ${err?.message ?? err}`, "red");
|
|
1486
|
+
}
|
|
1487
|
+
setPhase("done");
|
|
1488
|
+
}, [goal, props, push]);
|
|
1489
|
+
useInput((input, key) => {
|
|
1490
|
+
if (phase === "preview") {
|
|
1491
|
+
if (input === "y" || key.return) void start();
|
|
1492
|
+
else if (input === "e") {
|
|
1493
|
+
setDraft(goal);
|
|
1494
|
+
setPhase("input");
|
|
1495
|
+
} else if (input === "q") exit();
|
|
1496
|
+
} else if (phase === "done") {
|
|
1497
|
+
if (input === "q" || key.return) exit();
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
1501
|
+
/* @__PURE__ */ jsx(Header, { objectiveLabel: props.objectiveLabel, cost, tok, calls }),
|
|
1502
|
+
phase === "input" && /* @__PURE__ */ jsxs(Box, { children: [
|
|
1503
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", children: "What should Polymath do? " }),
|
|
1504
|
+
/* @__PURE__ */ jsx(
|
|
1505
|
+
TextInput,
|
|
1506
|
+
{
|
|
1507
|
+
value: draft,
|
|
1508
|
+
onChange: setDraft,
|
|
1509
|
+
onSubmit: (v) => {
|
|
1510
|
+
if (v.trim()) {
|
|
1511
|
+
setGoal(v.trim());
|
|
1512
|
+
setPhase("preview");
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
)
|
|
1517
|
+
] }),
|
|
1518
|
+
phase === "preview" && rec && /* @__PURE__ */ jsx(Preview, { rec }),
|
|
1519
|
+
(phase === "running" || phase === "done") && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
|
|
1520
|
+
log.slice(-18).map((l) => /* @__PURE__ */ jsx(Text, { color: l.color, children: l.text }, l.key)),
|
|
1521
|
+
phase === "running" && /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
1522
|
+
/* @__PURE__ */ jsx(Spinner, { type: "dots" }),
|
|
1523
|
+
" working\u2026"
|
|
1524
|
+
] }),
|
|
1525
|
+
phase === "done" && /* @__PURE__ */ jsxs(Text, { color: "green", children: [
|
|
1526
|
+
"\u2713 Done \xB7 ",
|
|
1527
|
+
calls,
|
|
1528
|
+
" calls \xB7 ",
|
|
1529
|
+
tokens(tok),
|
|
1530
|
+
" tokens \xB7 ",
|
|
1531
|
+
usd(cost),
|
|
1532
|
+
" \u2014 press q to quit"
|
|
1533
|
+
] })
|
|
1534
|
+
] })
|
|
1535
|
+
] });
|
|
1536
|
+
}
|
|
1537
|
+
function Header(props) {
|
|
1538
|
+
return /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [
|
|
1539
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1540
|
+
/* @__PURE__ */ jsx(Text, { color: "magentaBright", bold: true, children: "Polymath" }),
|
|
1541
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: " \xB7 policy: " }),
|
|
1542
|
+
/* @__PURE__ */ jsx(Text, { color: "yellow", children: props.objectiveLabel })
|
|
1543
|
+
] }),
|
|
1544
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
1545
|
+
props.calls,
|
|
1546
|
+
" calls \xB7 ",
|
|
1547
|
+
tokens(props.tok),
|
|
1548
|
+
" tok \xB7 ",
|
|
1549
|
+
/* @__PURE__ */ jsx(Text, { color: "green", children: usd(props.cost) })
|
|
1550
|
+
] })
|
|
1551
|
+
] });
|
|
1552
|
+
}
|
|
1553
|
+
function Preview(props) {
|
|
1554
|
+
const { rec } = props;
|
|
1555
|
+
const value = rec.strategies.find((s) => s.objective === "value");
|
|
1556
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
|
|
1557
|
+
/* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
1558
|
+
"Goal: ",
|
|
1559
|
+
rec.plan.goal
|
|
1560
|
+
] }),
|
|
1561
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: "Recommended routing (best value) \u2014 estimate before running:" }),
|
|
1562
|
+
value.assignments.map((a) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
1563
|
+
/* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
|
|
1564
|
+
" ",
|
|
1565
|
+
a.step.id,
|
|
1566
|
+
". [",
|
|
1567
|
+
a.step.type,
|
|
1568
|
+
"]"
|
|
1569
|
+
] }),
|
|
1570
|
+
" ",
|
|
1571
|
+
/* @__PURE__ */ jsx(Text, { color: "white", children: a.model ? a.model.id : "(none)" }),
|
|
1572
|
+
" ",
|
|
1573
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
1574
|
+
"~",
|
|
1575
|
+
usd(a.estCostUsd)
|
|
1576
|
+
] })
|
|
1577
|
+
] }, a.step.id)),
|
|
1578
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1579
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: " Est total: " }),
|
|
1580
|
+
/* @__PURE__ */ jsx(Text, { color: "green", children: usd(value.totalCostUsd) }),
|
|
1581
|
+
rec.savingsPct > 0 && /* @__PURE__ */ jsxs(Text, { color: "green", children: [
|
|
1582
|
+
" (~",
|
|
1583
|
+
rec.savingsPct.toFixed(0),
|
|
1584
|
+
"% vs all-frontier)"
|
|
1585
|
+
] })
|
|
1586
|
+
] }),
|
|
1587
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
|
|
1588
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", children: "Run this? " }),
|
|
1589
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: "[y] run \xB7 [e] edit goal \xB7 [q] quit" })
|
|
1590
|
+
] })
|
|
1591
|
+
] });
|
|
1592
|
+
}
|
|
1593
|
+
function truncate2(s, n) {
|
|
1594
|
+
return s.length > n ? s.slice(0, n) + "\u2026" : s;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// src/index.ts
|
|
1598
|
+
var program = new Command();
|
|
1599
|
+
program.name("poly").description("Polymath \u2014 cost-optimized, multi-model TUI coding agent").version("0.1.0");
|
|
1600
|
+
function client(config) {
|
|
1601
|
+
return new OpenRouterClient({
|
|
1602
|
+
apiKey: resolveApiKey(config),
|
|
1603
|
+
referer: config.referer,
|
|
1604
|
+
title: config.title
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
function buildPolicy(config, opts) {
|
|
1608
|
+
const objective = opts.objective || config.defaultObjective;
|
|
1609
|
+
const maxCost = opts.maxCost != null ? parseFloat(opts.maxCost) : config.maxCostPerCallUsd;
|
|
1610
|
+
return {
|
|
1611
|
+
objective,
|
|
1612
|
+
maxCostPerCallUsd: Number.isFinite(maxCost) ? maxCost : void 0,
|
|
1613
|
+
pinned: config.pinned
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
async function loadCatalog(config, refresh = false) {
|
|
1617
|
+
const models = await getModels(client(config), { refresh });
|
|
1618
|
+
if (!models.length) {
|
|
1619
|
+
console.error(c.red("Could not load the model catalog. Check your connection."));
|
|
1620
|
+
process.exit(1);
|
|
1621
|
+
}
|
|
1622
|
+
return models;
|
|
1623
|
+
}
|
|
1624
|
+
program.command("login").description("Connect Polymath to OpenRouter (set/replace your API key)").action(async () => {
|
|
1625
|
+
await runLogin();
|
|
1626
|
+
});
|
|
1627
|
+
program.command("run", { isDefault: true }).description("Launch the interactive agent (TUI)").argument("[goal...]", "what to do (optional; prompts if omitted)").option("-o, --objective <name>", "routing objective: cheapest | value | quality").option("--max-cost <usd>", "exclude models whose projected per-call cost exceeds this").option("-w, --write", "allow the agent to write files (confined to --cwd)", false).option("-x, --commands", "DANGER: let the model run arbitrary shell commands in --cwd", false).option("-C, --cwd <dir>", "working directory", process.cwd()).action(async (goalParts, opts) => {
|
|
1628
|
+
const config = loadConfig();
|
|
1629
|
+
const key = await ensureApiKey(config);
|
|
1630
|
+
if (!key) {
|
|
1631
|
+
console.error(c.red("No API key \u2014 cannot run. Try `poly login`."));
|
|
1632
|
+
process.exit(1);
|
|
1633
|
+
}
|
|
1634
|
+
const reloaded = loadConfig();
|
|
1635
|
+
const models = await loadCatalog(reloaded);
|
|
1636
|
+
const policy = buildPolicy(reloaded, opts);
|
|
1637
|
+
const goal = goalParts?.join(" ").trim() || void 0;
|
|
1638
|
+
const instance = render(
|
|
1639
|
+
createElement(App, {
|
|
1640
|
+
client: client(reloaded),
|
|
1641
|
+
models,
|
|
1642
|
+
policy,
|
|
1643
|
+
sessionId: randomUUID(),
|
|
1644
|
+
cwd: opts.cwd,
|
|
1645
|
+
allowWrite: !!opts.write,
|
|
1646
|
+
allowCommands: !!opts.commands,
|
|
1647
|
+
objectiveLabel: policy.objective,
|
|
1648
|
+
initialGoal: goal
|
|
1649
|
+
})
|
|
1650
|
+
);
|
|
1651
|
+
await instance.waitUntilExit();
|
|
1652
|
+
});
|
|
1653
|
+
program.command("recommend").description("Recommend the best / best-value model combos for a task BEFORE running").argument("<goal...>", "task description").option("--smart", "use an LLM to produce a tailored plan (costs a few cents)", false).option("-o, --objective <name>", "highlight a specific objective").action(async (goalParts, opts) => {
|
|
1654
|
+
const config = loadConfig();
|
|
1655
|
+
const models = await loadCatalog(config);
|
|
1656
|
+
const goal = goalParts.join(" ");
|
|
1657
|
+
let plan = heuristicPlan(goal);
|
|
1658
|
+
if (opts.smart) {
|
|
1659
|
+
const key = resolveApiKey(config);
|
|
1660
|
+
if (!key) {
|
|
1661
|
+
console.error(c.yellow("--smart needs an API key; falling back to heuristic plan. Run `poly login`."));
|
|
1662
|
+
} else {
|
|
1663
|
+
const planRoute = route("plan", models, buildPolicy(config, {}));
|
|
1664
|
+
if (planRoute) {
|
|
1665
|
+
try {
|
|
1666
|
+
plan = await planRequest(goal, client(config), planRoute.model);
|
|
1667
|
+
} catch (e) {
|
|
1668
|
+
console.error(c.yellow(`Smart plan failed (${e?.message}); using heuristic.`));
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
console.log(renderRecommendation(buildRecommendation(plan, models)));
|
|
1674
|
+
});
|
|
1675
|
+
program.command("models").description("Browse the model catalog with pricing and tiers").option("-t, --tier <tier>", "filter by tier: cheap | standard | frontier").option("--tools", "only models that support tool/function calling", false).option("-s, --search <text>", "filter by id/name substring").option("--refresh", "force-refresh the catalog from OpenRouter", false).option("-n, --limit <n>", "max rows", "40").action(async (opts) => {
|
|
1676
|
+
const config = loadConfig();
|
|
1677
|
+
let models = await loadCatalog(config, !!opts.refresh);
|
|
1678
|
+
if (opts.tier) models = models.filter((m) => m.tier === opts.tier);
|
|
1679
|
+
if (opts.tools) models = models.filter((m) => m.capabilities.tools);
|
|
1680
|
+
if (opts.search) {
|
|
1681
|
+
const q = String(opts.search).toLowerCase();
|
|
1682
|
+
models = models.filter((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q));
|
|
1683
|
+
}
|
|
1684
|
+
models.sort((a, b) => blendedPrice(a) - blendedPrice(b));
|
|
1685
|
+
const limit = parseInt(opts.limit, 10) || 40;
|
|
1686
|
+
const rows = models.slice(0, limit).map((m) => [
|
|
1687
|
+
m.id,
|
|
1688
|
+
tierColor(m.tier),
|
|
1689
|
+
perMTok(m.pricing.promptUsdPerMTok),
|
|
1690
|
+
perMTok(m.pricing.completionUsdPerMTok),
|
|
1691
|
+
m.capabilities.tools ? "\u2713" : "",
|
|
1692
|
+
String(m.contextLength)
|
|
1693
|
+
]);
|
|
1694
|
+
console.log(table(["Model", "Tier", "In", "Out", "Tools", "Ctx"], rows));
|
|
1695
|
+
console.log(c.dim(`
|
|
1696
|
+
${models.length} models match \xB7 showing ${rows.length}`));
|
|
1697
|
+
});
|
|
1698
|
+
program.command("usage").description("Show recorded usage & cost by date + model").option("--since <date>", "YYYY-MM-DD inclusive").option("--until <date>", "YYYY-MM-DD inclusive").option("--today", "only today", false).option("--sync", "also push unsynced rows to Firestore", false).action(async (opts) => {
|
|
1699
|
+
const config = loadConfig();
|
|
1700
|
+
let since = opts.since;
|
|
1701
|
+
let until = opts.until;
|
|
1702
|
+
if (opts.today) {
|
|
1703
|
+
const d = /* @__PURE__ */ new Date();
|
|
1704
|
+
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
|
1705
|
+
d.getDate()
|
|
1706
|
+
).padStart(2, "0")}`;
|
|
1707
|
+
since = iso;
|
|
1708
|
+
until = iso;
|
|
1709
|
+
}
|
|
1710
|
+
console.log(renderUsageReport({ since, until }));
|
|
1711
|
+
if (opts.sync) {
|
|
1712
|
+
const res = await syncUsage(config);
|
|
1713
|
+
console.log(res.synced > 0 ? c.green(res.message) : c.dim(res.message));
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
program.command("sync").description("Push unsynced usage rows to Firestore (mathology-b8e3d)").action(async () => {
|
|
1717
|
+
const config = loadConfig();
|
|
1718
|
+
const res = await syncUsage(config);
|
|
1719
|
+
console.log(res.synced > 0 ? c.green(res.message) : c.yellow(res.message));
|
|
1720
|
+
});
|
|
1721
|
+
var cfg = program.command("config").description("View or change Polymath settings");
|
|
1722
|
+
cfg.command("show").description("Print the current config (key is masked)").action(() => {
|
|
1723
|
+
const config = loadConfig();
|
|
1724
|
+
const key = resolveApiKey(config);
|
|
1725
|
+
console.log(
|
|
1726
|
+
JSON.stringify(
|
|
1727
|
+
{ ...config, openrouterApiKey: key ? key.slice(0, 8) + "\u2026" + key.slice(-4) : null },
|
|
1728
|
+
null,
|
|
1729
|
+
2
|
|
1730
|
+
)
|
|
1731
|
+
);
|
|
1732
|
+
});
|
|
1733
|
+
cfg.command("set").description("Set a setting: objective <cheapest|value|quality> | maxcost <usd> | referer <url> | title <text>").argument("<key>").argument("<value>").action((key, value) => {
|
|
1734
|
+
const config = loadConfig();
|
|
1735
|
+
switch (key) {
|
|
1736
|
+
case "objective":
|
|
1737
|
+
config.defaultObjective = value;
|
|
1738
|
+
break;
|
|
1739
|
+
case "maxcost":
|
|
1740
|
+
config.maxCostPerCallUsd = parseFloat(value);
|
|
1741
|
+
break;
|
|
1742
|
+
case "referer":
|
|
1743
|
+
config.referer = value;
|
|
1744
|
+
break;
|
|
1745
|
+
case "title":
|
|
1746
|
+
config.title = value;
|
|
1747
|
+
break;
|
|
1748
|
+
default:
|
|
1749
|
+
console.error(c.red(`Unknown setting: ${key}`));
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
saveConfig(config);
|
|
1753
|
+
console.log(c.green(`Set ${key} = ${value}`));
|
|
1754
|
+
});
|
|
1755
|
+
cfg.command("firestore").description("Enable/disable Firestore sync: on | off").argument("<state>").action((state) => {
|
|
1756
|
+
const config = loadConfig();
|
|
1757
|
+
config.firestore.enabled = /^on|true|1$/i.test(state);
|
|
1758
|
+
saveConfig(config);
|
|
1759
|
+
console.log(c.green(`Firestore sync ${config.firestore.enabled ? "enabled" : "disabled"}.`));
|
|
1760
|
+
});
|
|
1761
|
+
program.parseAsync().catch((err) => {
|
|
1762
|
+
console.error(c.red(err?.message ?? String(err)));
|
|
1763
|
+
process.exit(1);
|
|
1764
|
+
});
|