openpoly 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.
@@ -0,0 +1,109 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, } from "node:fs";
4
+ /** Bump when the seeded defaults change in a way that should reseed configs. */
5
+ const CONFIG_VERSION = 3;
6
+ /** OpenRouter's OpenAI-compatible gateway — free open-source models + many more. */
7
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
8
+ export const OPENROUTER_MODELS_URL = `${OPENROUTER_BASE_URL}/models`;
9
+ const CONFIG_DIR = join(homedir(), ".config", "polycode");
10
+ const GLOBAL_CONFIG_PATH = join(CONFIG_DIR, "config.json");
11
+ const PROJECT_CONFIG_NAME = ".polycode.json";
12
+ const KNOWN_PROVIDERS = [
13
+ "openai",
14
+ "anthropic",
15
+ "google",
16
+ "openai-compatible",
17
+ "openrouter",
18
+ ];
19
+ /** A config is stale if it references a provider we no longer support (e.g. an
20
+ * old Puter seed). Such configs are migrated by reseeding the defaults. */
21
+ function isStale(cfg) {
22
+ return (cfg.models ?? []).some((m) => !KNOWN_PROVIDERS.includes(m.provider));
23
+ }
24
+ /** Sensible starter config seeded on first run. Defaults to free, open-source
25
+ * models on OpenRouter (one free API key, no credit card), plus local Ollama. */
26
+ export function defaultConfig() {
27
+ return {
28
+ version: CONFIG_VERSION,
29
+ defaultModel: "gpt-oss",
30
+ models: [
31
+ // --- OpenRouter free, open-source, tool-capable models (OPENROUTER_API_KEY) ---
32
+ { name: "gpt-oss", provider: "openrouter", model: "openai/gpt-oss-120b:free" },
33
+ { name: "gpt-oss-mini", provider: "openrouter", model: "openai/gpt-oss-20b:free" },
34
+ { name: "qwen-coder", provider: "openrouter", model: "qwen/qwen3-coder:free" },
35
+ { name: "llama", provider: "openrouter", model: "meta-llama/llama-3.3-70b-instruct:free" },
36
+ { name: "nemotron", provider: "openrouter", model: "nvidia/nemotron-nano-9b-v2:free" },
37
+ // --- Local, fully free, no key (requires Ollama running) ---
38
+ { name: "deepseek-local", provider: "openai-compatible", model: "deepseek-r1", baseURL: "http://localhost:11434/v1" },
39
+ { name: "glm-local", provider: "openai-compatible", model: "glm4", baseURL: "http://localhost:11434/v1" },
40
+ { name: "qwen-local", provider: "openai-compatible", model: "qwen3", baseURL: "http://localhost:11434/v1" },
41
+ // --- Direct providers (bring your own API keys) ---
42
+ { name: "gpt-4o-direct", provider: "openai", model: "gpt-4o" },
43
+ { name: "claude-direct", provider: "anthropic", model: "claude-3-5-sonnet-latest" },
44
+ { name: "gemini-direct", provider: "google", model: "gemini-1.5-pro-latest" },
45
+ ],
46
+ };
47
+ }
48
+ /** Build a config entry for an arbitrary OpenRouter model id (used by
49
+ * `/model <id>` and the live menu, so any OpenRouter model works directly). */
50
+ export function openRouterModelConfig(modelId) {
51
+ return { name: modelId, provider: "openrouter", model: modelId };
52
+ }
53
+ function readJson(path) {
54
+ try {
55
+ return JSON.parse(readFileSync(path, "utf8"));
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /**
62
+ * Load config by merging the global config with an optional project-level
63
+ * `.polycode.json` (project values win). Seeds a global default on first run.
64
+ */
65
+ export function loadConfig(cwd = process.cwd()) {
66
+ let seeded = false;
67
+ const existing = readJson(GLOBAL_CONFIG_PATH);
68
+ const outdated = !existing || existing.version !== CONFIG_VERSION || isStale(existing);
69
+ if (outdated) {
70
+ // Preserve any prior config (e.g. user edits) before reseeding.
71
+ if (existing) {
72
+ try {
73
+ copyFileSync(GLOBAL_CONFIG_PATH, `${GLOBAL_CONFIG_PATH}.bak`);
74
+ }
75
+ catch {
76
+ /* best effort */
77
+ }
78
+ }
79
+ saveGlobalConfig(defaultConfig());
80
+ seeded = true;
81
+ }
82
+ const global = (seeded ? defaultConfig() : existing) ?? defaultConfig();
83
+ const project = readJson(join(cwd, PROJECT_CONFIG_NAME)) ?? {};
84
+ const merged = {
85
+ defaultModel: project.defaultModel ?? global.defaultModel ?? "qwen-coder",
86
+ models: mergeModels(global.models ?? [], project.models ?? []),
87
+ };
88
+ return { config: merged, seeded };
89
+ }
90
+ /** Project models override global ones with the same name; new ones are added. */
91
+ function mergeModels(globalModels, projectModels) {
92
+ const byName = new Map();
93
+ for (const m of globalModels)
94
+ byName.set(m.name, m);
95
+ for (const m of projectModels)
96
+ byName.set(m.name, m);
97
+ return [...byName.values()];
98
+ }
99
+ export function saveGlobalConfig(config) {
100
+ if (!existsSync(CONFIG_DIR))
101
+ mkdirSync(CONFIG_DIR, { recursive: true });
102
+ writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
103
+ }
104
+ export function globalConfigPath() {
105
+ return GLOBAL_CONFIG_PATH;
106
+ }
107
+ export function findModel(config, name) {
108
+ return config.models.find((m) => m.name === name);
109
+ }
@@ -0,0 +1,101 @@
1
+ import { createOpenAI } from "@ai-sdk/openai";
2
+ import { createAnthropic } from "@ai-sdk/anthropic";
3
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
4
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
5
+ import { OPENROUTER_BASE_URL, OPENROUTER_MODELS_URL, } from "../config/config.js";
6
+ const DEFAULT_KEY_ENV = {
7
+ openai: "OPENAI_API_KEY",
8
+ anthropic: "ANTHROPIC_API_KEY",
9
+ google: "GOOGLE_GENERATIVE_AI_API_KEY",
10
+ "openai-compatible": "OPENAI_API_KEY",
11
+ openrouter: "OPENROUTER_API_KEY",
12
+ };
13
+ // Optional ranking headers OpenRouter uses to attribute traffic to an app.
14
+ const OPENROUTER_HEADERS = {
15
+ "HTTP-Referer": "https://github.com/polycode",
16
+ "X-Title": "polycode",
17
+ };
18
+ export class ProviderError extends Error {
19
+ }
20
+ /** The environment variable that holds the API key for a given model. */
21
+ export function keyEnvFor(cfg) {
22
+ return cfg.apiKeyEnv ?? DEFAULT_KEY_ENV[cfg.provider] ?? "API_KEY";
23
+ }
24
+ /** Whether a model needs an API key (local openai-compatible servers don't). */
25
+ export function needsKey(cfg) {
26
+ return cfg.provider !== "openai-compatible";
27
+ }
28
+ /**
29
+ * Resolve a config entry into a concrete Vercel AI SDK model instance.
30
+ * Throws a friendly ProviderError when the required API key is missing.
31
+ */
32
+ export function buildModel(cfg) {
33
+ const keyEnv = cfg.apiKeyEnv ?? DEFAULT_KEY_ENV[cfg.provider];
34
+ const apiKey = keyEnv ? process.env[keyEnv] : undefined;
35
+ // Local openai-compatible servers (e.g. Ollama) need no real key.
36
+ const requiresKey = cfg.provider !== "openai-compatible";
37
+ if (requiresKey && !apiKey) {
38
+ if (cfg.provider === "openrouter") {
39
+ throw new ProviderError(`Missing OpenRouter key. Get a free one (no credit card) at https://openrouter.ai/keys and set OPENROUTER_API_KEY.`);
40
+ }
41
+ throw new ProviderError(`Missing API key for model "${cfg.name}". Set the ${keyEnv} environment variable.`);
42
+ }
43
+ switch (cfg.provider) {
44
+ case "openai":
45
+ return createOpenAI({ apiKey })(cfg.model);
46
+ case "anthropic":
47
+ return createAnthropic({ apiKey })(cfg.model);
48
+ case "google":
49
+ return createGoogleGenerativeAI({ apiKey })(cfg.model);
50
+ case "openai-compatible":
51
+ if (!cfg.baseURL) {
52
+ throw new ProviderError(`Model "${cfg.name}" uses provider "openai-compatible" but has no baseURL.`);
53
+ }
54
+ return createOpenAICompatible({
55
+ name: cfg.name,
56
+ baseURL: cfg.baseURL,
57
+ apiKey: apiKey ?? "not-needed",
58
+ })(cfg.model);
59
+ case "openrouter":
60
+ return createOpenAICompatible({
61
+ name: "openrouter",
62
+ baseURL: OPENROUTER_BASE_URL,
63
+ apiKey: apiKey,
64
+ headers: OPENROUTER_HEADERS,
65
+ })(cfg.model);
66
+ default:
67
+ throw new ProviderError(`Unknown provider "${cfg.provider}".`);
68
+ }
69
+ }
70
+ /**
71
+ * Fetch OpenRouter's live catalog and return every **tool-capable** model (the
72
+ * ones usable by this coding agent), flagging which are free. Free models come
73
+ * first, then alphabetical. Throws on network failure. (The free set rotates;
74
+ * DeepSeek/GLM/Kimi show up as free whenever they're offered free.)
75
+ */
76
+ export async function listModels() {
77
+ let res;
78
+ try {
79
+ res = await fetch(OPENROUTER_MODELS_URL);
80
+ }
81
+ catch (err) {
82
+ throw new ProviderError(`Could not reach OpenRouter: ${err instanceof Error ? err.message : String(err)}`);
83
+ }
84
+ if (!res.ok) {
85
+ throw new ProviderError(`OpenRouter returned ${res.status} ${res.statusText} when listing models.`);
86
+ }
87
+ const body = (await res.json());
88
+ const models = [];
89
+ for (const m of body.data ?? []) {
90
+ if (!m.id)
91
+ continue;
92
+ const hasTools = m.supported_parameters?.includes("tools") ?? false;
93
+ if (!hasTools)
94
+ continue; // the agent needs tool calling
95
+ const free = m.id.endsWith(":free") || m.pricing?.prompt === "0";
96
+ models.push({ id: m.id, free });
97
+ }
98
+ // Free first, then alphabetical, so the free options are front and centre.
99
+ models.sort((a, b) => a.free === b.free ? a.id.localeCompare(b.id) : a.free ? -1 : 1);
100
+ return models;
101
+ }
@@ -0,0 +1,18 @@
1
+ import { resolve, relative, isAbsolute } from "node:path";
2
+ /**
3
+ * Resolve a user/model-supplied path against the working directory and refuse
4
+ * anything that escapes it. Keeps the agent contained to the project.
5
+ */
6
+ export function resolveSafe(cwd, p) {
7
+ const abs = isAbsolute(p) ? p : resolve(cwd, p);
8
+ const rel = relative(cwd, abs);
9
+ if (rel.startsWith("..") || isAbsolute(rel)) {
10
+ throw new Error(`Path "${p}" is outside the working directory.`);
11
+ }
12
+ return abs;
13
+ }
14
+ /** Display a path relative to cwd for tidy UI output. */
15
+ export function displayPath(cwd, abs) {
16
+ const rel = relative(cwd, abs);
17
+ return rel === "" ? "." : rel;
18
+ }
@@ -0,0 +1,192 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { exec } from "node:child_process";
6
+ import { resolveSafe, displayPath } from "./fsutil.js";
7
+ const IGNORED_DIRS = new Set([
8
+ "node_modules",
9
+ ".git",
10
+ "dist",
11
+ "build",
12
+ ".next",
13
+ ".vexp",
14
+ ]);
15
+ /**
16
+ * Build the tool set, capturing the working directory and permission broker in
17
+ * a closure so the Vercel AI SDK can call them without extra context plumbing.
18
+ */
19
+ export function createTools(cwd, broker) {
20
+ return {
21
+ read_file: tool({
22
+ description: "Read the contents of a file in the project. Returns the text with line numbers.",
23
+ parameters: z.object({
24
+ path: z.string().describe("File path relative to the project root."),
25
+ }),
26
+ execute: async ({ path }) => {
27
+ const abs = resolveSafe(cwd, path);
28
+ if (!existsSync(abs))
29
+ return `Error: file not found: ${path}`;
30
+ const text = readFileSync(abs, "utf8");
31
+ const numbered = text
32
+ .split("\n")
33
+ .map((line, i) => `${String(i + 1).padStart(5)} ${line}`)
34
+ .join("\n");
35
+ return numbered;
36
+ },
37
+ }),
38
+ write_file: tool({
39
+ description: "Create a new file or overwrite an existing one with the given content.",
40
+ parameters: z.object({
41
+ path: z.string().describe("File path relative to the project root."),
42
+ content: z.string().describe("Full content to write."),
43
+ }),
44
+ execute: async ({ path, content }) => {
45
+ const abs = resolveSafe(cwd, path);
46
+ const exists = existsSync(abs);
47
+ const ok = await broker.request("write_file", `${exists ? "Overwrite" : "Create"} file: ${displayPath(cwd, abs)}`, previewContent(content));
48
+ if (!ok)
49
+ return "Denied by user.";
50
+ mkdirSync(dirname(abs), { recursive: true });
51
+ writeFileSync(abs, content, "utf8");
52
+ return `Wrote ${content.length} bytes to ${displayPath(cwd, abs)}`;
53
+ },
54
+ }),
55
+ edit_file: tool({
56
+ description: "Replace an exact string in a file with new text. The old string must appear exactly once.",
57
+ parameters: z.object({
58
+ path: z.string().describe("File path relative to the project root."),
59
+ old_string: z.string().describe("Exact text to replace."),
60
+ new_string: z.string().describe("Replacement text."),
61
+ }),
62
+ execute: async ({ path, old_string, new_string }) => {
63
+ const abs = resolveSafe(cwd, path);
64
+ if (!existsSync(abs))
65
+ return `Error: file not found: ${path}`;
66
+ const original = readFileSync(abs, "utf8");
67
+ const count = original.split(old_string).length - 1;
68
+ if (count === 0)
69
+ return "Error: old_string not found in file.";
70
+ if (count > 1)
71
+ return `Error: old_string appears ${count} times; it must be unique.`;
72
+ const updated = original.replace(old_string, new_string);
73
+ const ok = await broker.request("edit_file", `Edit file: ${displayPath(cwd, abs)}`, diffPreview(old_string, new_string));
74
+ if (!ok)
75
+ return "Denied by user.";
76
+ writeFileSync(abs, updated, "utf8");
77
+ return `Edited ${displayPath(cwd, abs)}`;
78
+ },
79
+ }),
80
+ run_command: tool({
81
+ description: "Run a shell command in the project directory and return its output. Use for tests, git, builds, etc.",
82
+ parameters: z.object({
83
+ command: z.string().describe("The shell command to run."),
84
+ }),
85
+ execute: async ({ command }) => {
86
+ const ok = await broker.request("run_command", `Run command: ${command}`);
87
+ if (!ok)
88
+ return "Denied by user.";
89
+ return runShell(command, cwd);
90
+ },
91
+ }),
92
+ search_code: tool({
93
+ description: "Search the project for a text pattern (literal or regex). Returns matching files and lines.",
94
+ parameters: z.object({
95
+ pattern: z.string().describe("Text or regular expression to find."),
96
+ path: z
97
+ .string()
98
+ .optional()
99
+ .describe("Optional subdirectory to search within."),
100
+ }),
101
+ execute: async ({ pattern, path }) => {
102
+ const root = resolveSafe(cwd, path ?? ".");
103
+ let regex;
104
+ try {
105
+ regex = new RegExp(pattern, "i");
106
+ }
107
+ catch {
108
+ regex = new RegExp(escapeRegex(pattern), "i");
109
+ }
110
+ const results = [];
111
+ walk(root, (file) => {
112
+ let text;
113
+ try {
114
+ text = readFileSync(file, "utf8");
115
+ }
116
+ catch {
117
+ return; // skip binary/unreadable
118
+ }
119
+ text.split("\n").forEach((line, i) => {
120
+ if (regex.test(line) && results.length < 100) {
121
+ results.push(`${displayPath(cwd, file)}:${i + 1}: ${line.trim()}`);
122
+ }
123
+ });
124
+ });
125
+ return results.length
126
+ ? results.join("\n")
127
+ : `No matches for "${pattern}".`;
128
+ },
129
+ }),
130
+ };
131
+ }
132
+ function previewContent(content) {
133
+ const lines = content.split("\n");
134
+ const head = lines.slice(0, 20).join("\n");
135
+ return lines.length > 20 ? `${head}\n… (${lines.length} lines total)` : head;
136
+ }
137
+ function diffPreview(oldStr, newStr) {
138
+ const minus = oldStr
139
+ .split("\n")
140
+ .map((l) => `- ${l}`)
141
+ .join("\n");
142
+ const plus = newStr
143
+ .split("\n")
144
+ .map((l) => `+ ${l}`)
145
+ .join("\n");
146
+ return `${minus}\n${plus}`;
147
+ }
148
+ function escapeRegex(s) {
149
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
150
+ }
151
+ function walk(dir, onFile) {
152
+ let entries;
153
+ try {
154
+ entries = readdirSync(dir, { withFileTypes: true });
155
+ }
156
+ catch {
157
+ return;
158
+ }
159
+ for (const entry of entries) {
160
+ if (entry.isDirectory()) {
161
+ if (IGNORED_DIRS.has(entry.name))
162
+ continue;
163
+ walk(join(dir, entry.name), onFile);
164
+ }
165
+ else if (entry.isFile()) {
166
+ const full = join(dir, entry.name);
167
+ try {
168
+ if (statSync(full).size > 1_000_000)
169
+ continue; // skip huge files
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ onFile(full);
175
+ }
176
+ }
177
+ }
178
+ function runShell(command, cwd) {
179
+ return new Promise((resolve) => {
180
+ exec(command, { cwd, timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
181
+ const out = [stdout, stderr].filter(Boolean).join("\n").trim();
182
+ if (error && error.code === "ETIMEDOUT") {
183
+ resolve(`Command timed out after 120s.\n${out}`);
184
+ return;
185
+ }
186
+ const exitInfo = error && typeof error.code === "number"
187
+ ? `\n[exit code ${error.code}]`
188
+ : "";
189
+ resolve((out || "(no output)") + exitInfo);
190
+ });
191
+ });
192
+ }
@@ -0,0 +1,32 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { randomUUID } from "node:crypto";
3
+ /**
4
+ * Bridges tool execution (which needs an answer) and the TUI (which asks the
5
+ * user). Tools `await request(...)`; the TUI listens for "request" events and
6
+ * calls `respond`. "always" remembers the tool for the rest of the session.
7
+ */
8
+ export class PermissionBroker extends EventEmitter {
9
+ alwaysAllowed = new Set();
10
+ /** Pre-approve a tool (used by --yolo / auto-approve mode). */
11
+ allowAlways(tool) {
12
+ this.alwaysAllowed.add(tool);
13
+ }
14
+ async request(tool, title, detail) {
15
+ if (this.alwaysAllowed.has(tool))
16
+ return true;
17
+ return new Promise((resolve) => {
18
+ const req = {
19
+ id: randomUUID(),
20
+ tool,
21
+ title,
22
+ detail,
23
+ respond: (decision) => {
24
+ if (decision === "always")
25
+ this.alwaysAllowed.add(tool);
26
+ resolve(decision !== "deny");
27
+ },
28
+ };
29
+ this.emit("request", req);
30
+ });
31
+ }
32
+ }