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.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/agent/agent.js +233 -0
- package/dist/agent/systemPrompt.js +18 -0
- package/dist/auth/keys.js +35 -0
- package/dist/cli.js +125 -0
- package/dist/config/config.js +109 -0
- package/dist/providers/index.js +101 -0
- package/dist/tools/fsutil.js +18 -0
- package/dist/tools/index.js +192 -0
- package/dist/tools/permissions.js +32 -0
- package/dist/tui/App.js +276 -0
- package/dist/tui/Banner.js +9 -0
- package/dist/tui/KeyPrompt.js +19 -0
- package/dist/tui/ModelMenu.js +22 -0
- package/dist/tui/PermissionDialog.js +17 -0
- package/dist/tui/StatusBar.js +6 -0
- package/dist/tui/Transcript.js +44 -0
- package/dist/tui/types.js +1 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 polycode contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# polycode
|
|
2
|
+
|
|
3
|
+
An open-source, model-agnostic terminal coding agent — like Claude Code, but with **free, open-source models**. Use DeepSeek, GLM, Qwen, Llama and more via [OpenRouter](https://openrouter.ai)'s free tier, run them locally with [Ollama](https://ollama.com), or bring your own GPT/Claude/Gemini keys — all from one terminal UI.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
› add input validation to the signup form
|
|
7
|
+
|
|
8
|
+
● read src/auth/signup.ts
|
|
9
|
+
● edit src/auth/signup.ts
|
|
10
|
+
● run npm test
|
|
11
|
+
I added validation and the tests pass.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Free, open-source models.** Use DeepSeek, GLM, Qwen, Llama, gpt-oss and more for free via [OpenRouter](https://openrouter.ai)'s free tier — one free API key, **no credit card**.
|
|
17
|
+
- **Fully local & keyless option.** Run open models on your own machine with [Ollama](https://ollama.com) — no key, no limits.
|
|
18
|
+
- **Any model.** Built on the [Vercel AI SDK](https://sdk.vercel.ai) — also works directly with OpenAI, Anthropic, Google, and any OpenAI-compatible endpoint using your own keys.
|
|
19
|
+
- **In-terminal model picker.** Run `/model` for a Claude-Code-style menu pre-loaded with the live list of all tool-capable models (250+) — free ones marked `·free` and listed first; type to filter, arrow keys to select.
|
|
20
|
+
- **Real coding tools.** Read, write, and edit files; run shell commands; search the codebase.
|
|
21
|
+
- **Permission prompts.** Approve each file write and command (`y` / `a` always / `n` deny), or run with `--yolo` to auto-approve.
|
|
22
|
+
- **Rich terminal UI** built with [Ink](https://github.com/vadimdemedes/ink): streaming output, colored diffs, live tool status.
|
|
23
|
+
- **Switch models on the fly** with `/model <name>` — your conversation is kept.
|
|
24
|
+
- **Adjustable thinking speed.** Reasoning models can be slow; `/think off|low|medium|high` dials reasoning effort (default `low` for speed). Press **Esc** to cancel a slow turn.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
### One command (anyone, anywhere)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g openpoly
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's it — no cloning, no folders. Now open **any** terminal, `cd` into a project, and run `polycode` (or `openpoly`). It works on whatever directory you're in. Update with `npm i -g openpoly@latest`; remove with `npm rm -g openpoly`.
|
|
35
|
+
|
|
36
|
+
### Install from source (for development)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# from anywhere — point npm at the project folder; it builds and installs globally
|
|
40
|
+
npm install -g /absolute/path/to/polycode
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
# Windows PowerShell
|
|
45
|
+
npm install -g "C:\path\to\polycode"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Now open **any** terminal, `cd` into a project you want help with, and run:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
polycode
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
To update later, re-run the install command. To remove it: `npm rm -g polycode`.
|
|
55
|
+
|
|
56
|
+
### Run from source (no global install)
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cd polycode
|
|
60
|
+
npm install
|
|
61
|
+
npm run dev
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Configure
|
|
65
|
+
|
|
66
|
+
### Recommended: free models via OpenRouter
|
|
67
|
+
|
|
68
|
+
1. Create a **free** OpenRouter API key (no credit card) at [openrouter.ai/keys](https://openrouter.ai/keys).
|
|
69
|
+
2. Run `polycode`. On first run it **prompts you to paste the key right in the terminal** (masked) and saves it for next time — no env-var setup needed. Type `/key` anytime to enter or change it.
|
|
70
|
+
3. The default model is `gpt-oss` (OpenAI gpt-oss-120b, free). Run **`/model`** (or `/models`) to open a picker populated with **all 250+ tool-capable models** — free ones marked `·free` and shown first; type to filter, arrow keys to move, enter to select. The free set rotates; DeepSeek/GLM/Kimi show up whenever they're offered free.
|
|
71
|
+
|
|
72
|
+
> Prefer env vars? Set `OPENROUTER_API_KEY` (PowerShell: `$env:OPENROUTER_API_KEY="sk-or-..."`) and the prompt is skipped. The terminal-entered key is stored at `~/.config/polycode/auth.json`.
|
|
73
|
+
|
|
74
|
+
> Free models are rate-limited (≈20 requests/min, ≈200/day) and shared, so they can be slower or briefly unavailable.
|
|
75
|
+
|
|
76
|
+
### Fully local & free with Ollama (no key)
|
|
77
|
+
|
|
78
|
+
Install [Ollama](https://ollama.com), pull a model, then pick a `*-local` model:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ollama pull qwen3 # or: ollama pull deepseek-r1 / glm4
|
|
82
|
+
polycode --model=qwen-local
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
No API key, no rate limits — it runs on your machine.
|
|
86
|
+
|
|
87
|
+
### Config file
|
|
88
|
+
|
|
89
|
+
On first run, polycode seeds `~/.config/polycode/config.json` (older configs are auto-migrated; the previous file is kept as `config.json.bak`):
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"version": 2,
|
|
94
|
+
"defaultModel": "qwen-coder",
|
|
95
|
+
"models": [
|
|
96
|
+
{ "name": "qwen-coder", "provider": "openrouter", "model": "qwen/qwen3-coder:free" },
|
|
97
|
+
{ "name": "llama", "provider": "openrouter", "model": "meta-llama/llama-3.3-70b-instruct:free" },
|
|
98
|
+
{ "name": "gpt-oss", "provider": "openrouter", "model": "openai/gpt-oss-120b:free" },
|
|
99
|
+
{ "name": "deepseek-local", "provider": "openai-compatible", "model": "deepseek-r1", "baseURL": "http://localhost:11434/v1" },
|
|
100
|
+
{ "name": "claude-direct", "provider": "anthropic", "model": "claude-3-5-sonnet-latest" }
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
With `OPENROUTER_API_KEY` set you can also `/model <any-openrouter-id>` directly without editing config (e.g. `/model deepseek/deepseek-chat-v3.1:free`).
|
|
106
|
+
|
|
107
|
+
### API keys (env vars, never written to disk)
|
|
108
|
+
|
|
109
|
+
| Provider | Env var |
|
|
110
|
+
| ------------------- | -------------------------------- |
|
|
111
|
+
| `openrouter` | `OPENROUTER_API_KEY` (free at openrouter.ai/keys) |
|
|
112
|
+
| `openai-compatible` | none for local Ollama/LM Studio |
|
|
113
|
+
| `openai` | `OPENAI_API_KEY` |
|
|
114
|
+
| `anthropic` | `ANTHROPIC_API_KEY` |
|
|
115
|
+
| `google` | `GOOGLE_GENERATIVE_AI_API_KEY` |
|
|
116
|
+
|
|
117
|
+
You can also drop a project-level `.polycode.json` in any repo to override or add models for that project.
|
|
118
|
+
|
|
119
|
+
## Usage
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
polycode # start with the default model
|
|
123
|
+
polycode --model=llama # start with a specific model
|
|
124
|
+
polycode --yolo # auto-approve writes and commands
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
In-session commands:
|
|
128
|
+
|
|
129
|
+
| Command | Description |
|
|
130
|
+
| --------------- | ------------------------------------------------ |
|
|
131
|
+
| `/think <lvl>` | reasoning effort: `off`/`low`/`medium`/`high` (lower = faster) |
|
|
132
|
+
| `/key` | enter/update the API key for the current model |
|
|
133
|
+
| `/model` | open the picker — free models, type to filter |
|
|
134
|
+
| `/models` | same as `/model` |
|
|
135
|
+
| `/model <name>` | switch directly (any OpenRouter model id works) |
|
|
136
|
+
| `/clear` | clear the conversation |
|
|
137
|
+
| `/help` | show help |
|
|
138
|
+
| `/exit` | quit |
|
|
139
|
+
|
|
140
|
+
## Architecture
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
src/
|
|
144
|
+
cli.tsx entry point — args, wiring, render
|
|
145
|
+
config/ load/merge config (versioned, auto-migrating), resolve keys
|
|
146
|
+
providers/ map a config entry → a Vercel AI SDK model; list free models
|
|
147
|
+
tools/ read / write / edit / run_command / search_code + permission broker
|
|
148
|
+
agent/ the agentic loop (streamText + tools, multi-step)
|
|
149
|
+
tui/ Ink components — transcript, permission dialog, status bar
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The agent loop leans on the Vercel AI SDK's multi-step tool calling so behavior is uniform across providers. Tools request approval through a `PermissionBroker` that bridges tool execution and the UI.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { streamText, generateText, } from "ai";
|
|
3
|
+
import { buildSystemPrompt } from "./systemPrompt.js";
|
|
4
|
+
/**
|
|
5
|
+
* Owns the conversation state and drives one agentic turn per `send()`.
|
|
6
|
+
* Relies on the Vercel AI SDK's multi-step loop (maxSteps) to handle the
|
|
7
|
+
* model<->tool round-trips uniformly across providers. Emits streaming events
|
|
8
|
+
* the TUI subscribes to.
|
|
9
|
+
*/
|
|
10
|
+
export class AgentSession extends EventEmitter {
|
|
11
|
+
model;
|
|
12
|
+
tools;
|
|
13
|
+
messages = [];
|
|
14
|
+
system;
|
|
15
|
+
busy = false;
|
|
16
|
+
controller = null;
|
|
17
|
+
// Default to "low" so reasoning models stay snappy; raise with /think.
|
|
18
|
+
reasoning = "low";
|
|
19
|
+
constructor(model, tools, cwd) {
|
|
20
|
+
super();
|
|
21
|
+
this.model = model;
|
|
22
|
+
this.tools = tools;
|
|
23
|
+
this.system = buildSystemPrompt(cwd);
|
|
24
|
+
}
|
|
25
|
+
/** Swap the underlying model mid-session (used by /model). History is kept. */
|
|
26
|
+
setModel(model) {
|
|
27
|
+
this.model = model;
|
|
28
|
+
}
|
|
29
|
+
hasModel() {
|
|
30
|
+
return this.model !== null;
|
|
31
|
+
}
|
|
32
|
+
/** Drop conversation history (used by /clear). */
|
|
33
|
+
clearHistory() {
|
|
34
|
+
this.messages = [];
|
|
35
|
+
}
|
|
36
|
+
isBusy() {
|
|
37
|
+
return this.busy;
|
|
38
|
+
}
|
|
39
|
+
setReasoning(level) {
|
|
40
|
+
this.reasoning = level;
|
|
41
|
+
}
|
|
42
|
+
getReasoning() {
|
|
43
|
+
return this.reasoning;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* OpenRouter reasoning control, passed through the openai-compatible provider
|
|
47
|
+
* (it spreads providerOptions["openrouter"] into the request body). Lower
|
|
48
|
+
* effort = less "thinking" = faster. Ignored by non-OpenRouter providers.
|
|
49
|
+
*/
|
|
50
|
+
reasoningOptions() {
|
|
51
|
+
const reasoning = this.reasoning === "off"
|
|
52
|
+
? { enabled: false }
|
|
53
|
+
: { effort: this.reasoning };
|
|
54
|
+
return { openrouter: { reasoning } };
|
|
55
|
+
}
|
|
56
|
+
/** Cancel the in-flight turn, if any (Esc). Triggers the stream to abort. */
|
|
57
|
+
abort() {
|
|
58
|
+
this.controller?.abort();
|
|
59
|
+
}
|
|
60
|
+
/** Emit an error, but report a user-initiated abort gently instead. */
|
|
61
|
+
emitError(err) {
|
|
62
|
+
if (this.controller?.signal.aborted)
|
|
63
|
+
this.emit("error", "⊘ Cancelled.");
|
|
64
|
+
else
|
|
65
|
+
this.emit("error", describeError(err));
|
|
66
|
+
}
|
|
67
|
+
async send(userText) {
|
|
68
|
+
if (this.busy)
|
|
69
|
+
return;
|
|
70
|
+
if (!this.model) {
|
|
71
|
+
this.emit("error", "No model configured. Use /model <name> to pick one, or set the provider's API key and restart.");
|
|
72
|
+
this.emit("done");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.busy = true;
|
|
76
|
+
this.controller = new AbortController();
|
|
77
|
+
this.messages.push({ role: "user", content: userText });
|
|
78
|
+
try {
|
|
79
|
+
const result = streamText({
|
|
80
|
+
model: this.model,
|
|
81
|
+
system: this.system,
|
|
82
|
+
messages: this.messages,
|
|
83
|
+
tools: this.tools,
|
|
84
|
+
maxSteps: 30,
|
|
85
|
+
// Free model endpoints are often briefly rate-limited (HTTP 429 with a
|
|
86
|
+
// short retry-after); a few extra backoff retries ride most of them out.
|
|
87
|
+
maxRetries: 4,
|
|
88
|
+
abortSignal: this.controller.signal,
|
|
89
|
+
providerOptions: this.reasoningOptions(),
|
|
90
|
+
// Weaker free models sometimes emit malformed tool calls (e.g. a
|
|
91
|
+
// write_file with no "path"). Re-ask the model to redo the call
|
|
92
|
+
// correctly instead of crashing the turn.
|
|
93
|
+
experimental_repairToolCall: async ({ toolCall, tools, error, messages, system }) => {
|
|
94
|
+
if (!this.model)
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
const repair = await generateText({
|
|
98
|
+
model: this.model,
|
|
99
|
+
system,
|
|
100
|
+
messages: [
|
|
101
|
+
...messages,
|
|
102
|
+
{
|
|
103
|
+
role: "user",
|
|
104
|
+
content: `Your previous ${toolCall.toolName} tool call was rejected: ${error.message}. Call ${toolCall.toolName} again with ALL required arguments. (For write_file you must include both "path" — a filename — and "content".)`,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
tools,
|
|
108
|
+
toolChoice: "required",
|
|
109
|
+
maxRetries: 1,
|
|
110
|
+
abortSignal: this.controller?.signal,
|
|
111
|
+
providerOptions: this.reasoningOptions(),
|
|
112
|
+
});
|
|
113
|
+
const fixed = repair.toolCalls.find((tc) => tc.toolName === toolCall.toolName) ??
|
|
114
|
+
repair.toolCalls[0];
|
|
115
|
+
if (!fixed)
|
|
116
|
+
return null;
|
|
117
|
+
return {
|
|
118
|
+
toolCallType: "function",
|
|
119
|
+
toolCallId: toolCall.toolCallId,
|
|
120
|
+
toolName: fixed.toolName,
|
|
121
|
+
args: JSON.stringify(fixed.args),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
let streamErrored = false;
|
|
130
|
+
for await (const part of result.fullStream) {
|
|
131
|
+
switch (part.type) {
|
|
132
|
+
case "text-delta":
|
|
133
|
+
this.emit("text-delta", part.textDelta);
|
|
134
|
+
break;
|
|
135
|
+
case "tool-call":
|
|
136
|
+
this.emit("tool-call", {
|
|
137
|
+
toolName: part.toolName,
|
|
138
|
+
args: part.args,
|
|
139
|
+
});
|
|
140
|
+
break;
|
|
141
|
+
case "tool-result":
|
|
142
|
+
this.emit("tool-result", {
|
|
143
|
+
toolName: part.toolName,
|
|
144
|
+
result: part.result,
|
|
145
|
+
});
|
|
146
|
+
break;
|
|
147
|
+
case "error":
|
|
148
|
+
// The stream surfaced an error (e.g. a rate limit). The loop ends
|
|
149
|
+
// here, but result.response would never resolve — awaiting it would
|
|
150
|
+
// hang forever (busy stuck on "thinking…"). So bail out instead.
|
|
151
|
+
streamErrored = true;
|
|
152
|
+
this.emitError(part.error);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!streamErrored) {
|
|
157
|
+
const response = await result.response;
|
|
158
|
+
this.messages.push(...response.messages);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
this.emitError(err);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
this.controller = null;
|
|
166
|
+
// Reset busy BEFORE emitting done so the UI never sees done while busy.
|
|
167
|
+
this.busy = false;
|
|
168
|
+
this.emit("done");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Build a detailed, human-readable error string. The Vercel AI SDK wraps the
|
|
174
|
+
* real failure in RetryError -> APICallError, where the actual provider reason
|
|
175
|
+
* (HTTP status + response body) lives. We unwrap that chain so errors like
|
|
176
|
+
* OpenRouter's "Provider returned error" show what really happened.
|
|
177
|
+
*/
|
|
178
|
+
function describeError(err) {
|
|
179
|
+
if (typeof err === "string")
|
|
180
|
+
return err;
|
|
181
|
+
// Tool-argument validation failures echo the entire (often huge) argument
|
|
182
|
+
// blob. Collapse them to a concise, actionable line.
|
|
183
|
+
const msg = err instanceof Error ? err.message : "";
|
|
184
|
+
if (/Invalid arguments for tool|Type validation failed|No such tool/i.test(msg)) {
|
|
185
|
+
const tool = /tool[: ]+["']?(\w+)/i.exec(msg)?.[1];
|
|
186
|
+
return `The model sent an invalid tool call${tool ? ` for ${tool}` : ""} and couldn't be repaired. Try again, or switch models with /model — some free models struggle with large file writes.`;
|
|
187
|
+
}
|
|
188
|
+
const parts = [];
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
let e = err;
|
|
191
|
+
while (e && typeof e === "object" && !seen.has(e)) {
|
|
192
|
+
seen.add(e);
|
|
193
|
+
if (typeof e.statusCode === "number")
|
|
194
|
+
parts.push(`HTTP ${e.statusCode}`);
|
|
195
|
+
if (typeof e.message === "string" && e.message)
|
|
196
|
+
parts.push(e.message);
|
|
197
|
+
// The provider's raw error body usually holds the real reason.
|
|
198
|
+
const body = e.responseBody ?? e.data;
|
|
199
|
+
if (body) {
|
|
200
|
+
const text = typeof body === "string" ? body : safeJson(body);
|
|
201
|
+
if (text)
|
|
202
|
+
parts.push(text);
|
|
203
|
+
}
|
|
204
|
+
// Descend into wrapped errors (RetryError.lastError, Error.cause).
|
|
205
|
+
e = e.lastError ?? e.cause ?? undefined;
|
|
206
|
+
}
|
|
207
|
+
// De-duplicate (the wrapper message often repeats the inner one) and trim.
|
|
208
|
+
const seenText = new Set();
|
|
209
|
+
const unique = parts.filter((p) => {
|
|
210
|
+
const key = p.trim();
|
|
211
|
+
if (!key || seenText.has(key))
|
|
212
|
+
return false;
|
|
213
|
+
seenText.add(key);
|
|
214
|
+
return true;
|
|
215
|
+
});
|
|
216
|
+
let out = unique.join("\n");
|
|
217
|
+
if (out.length > 1500)
|
|
218
|
+
out = out.slice(0, 1500) + "…";
|
|
219
|
+
// Rate limits are the common free-tier failure — guide the user to options.
|
|
220
|
+
if (/\b429\b|rate.?limit/i.test(out)) {
|
|
221
|
+
out +=
|
|
222
|
+
"\n\nThis free model is busy. Retry in a moment, run /model to pick another free model, or add your own provider key with /key.";
|
|
223
|
+
}
|
|
224
|
+
return out || (err instanceof Error ? err.message : String(err));
|
|
225
|
+
}
|
|
226
|
+
function safeJson(value) {
|
|
227
|
+
try {
|
|
228
|
+
return JSON.stringify(value);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return String(value);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function buildSystemPrompt(cwd) {
|
|
2
|
+
return `You are polycode, an open-source AI coding agent running in a terminal.
|
|
3
|
+
You help the user with software engineering tasks in their project.
|
|
4
|
+
|
|
5
|
+
Working directory: ${cwd}
|
|
6
|
+
Operating system: ${process.platform}
|
|
7
|
+
|
|
8
|
+
Guidelines:
|
|
9
|
+
- Use the provided tools to read, search, write, and edit files, and to run shell commands. Do not guess file contents — read them.
|
|
10
|
+
- Before editing a file, read it so your edits match the existing code exactly.
|
|
11
|
+
- Prefer edit_file for small changes and write_file for new files.
|
|
12
|
+
- write_file ALWAYS needs both "path" (a filename) and "content". For large files, write them in one complete call; don't omit the path.
|
|
13
|
+
- When running commands, choose the smallest command that accomplishes the goal.
|
|
14
|
+
- Explain what you are about to do briefly, then act. Keep prose concise.
|
|
15
|
+
- After making changes, verify them (e.g. run tests or the build) when reasonable.
|
|
16
|
+
- The user must approve file writes and shell commands; if a tool returns "Denied by user", stop and ask how to proceed.
|
|
17
|
+
- Never invent file paths. Use search_code to locate things you are unsure about.`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
const KEYS_PATH = join(homedir(), ".config", "polycode", "auth.json");
|
|
5
|
+
function read() {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(readFileSync(KEYS_PATH, "utf8"));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Persist an API key locally and export it to the current process env. */
|
|
14
|
+
export function saveKey(envVar, value) {
|
|
15
|
+
const store = read();
|
|
16
|
+
store[envVar] = value;
|
|
17
|
+
mkdirSync(dirname(KEYS_PATH), { recursive: true });
|
|
18
|
+
writeFileSync(KEYS_PATH, JSON.stringify(store, null, 2), "utf8");
|
|
19
|
+
process.env[envVar] = value;
|
|
20
|
+
}
|
|
21
|
+
export function clearKeys() {
|
|
22
|
+
try {
|
|
23
|
+
rmSync(KEYS_PATH);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* nothing stored */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Load stored keys into the environment without overriding real env vars. */
|
|
30
|
+
export function applyStoredKeys() {
|
|
31
|
+
for (const [envVar, value] of Object.entries(read())) {
|
|
32
|
+
if (value && !process.env[envVar])
|
|
33
|
+
process.env[envVar] = value;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { App } from "./tui/App.js";
|
|
5
|
+
import { loadConfig, findModel, globalConfigPath, openRouterModelConfig, } from "./config/config.js";
|
|
6
|
+
import { buildModel, listModels, keyEnvFor, needsKey, ProviderError, } from "./providers/index.js";
|
|
7
|
+
import { createTools } from "./tools/index.js";
|
|
8
|
+
import { PermissionBroker } from "./tools/permissions.js";
|
|
9
|
+
import { AgentSession } from "./agent/agent.js";
|
|
10
|
+
import { applyStoredKeys, saveKey } from "./auth/keys.js";
|
|
11
|
+
const VERSION = "0.1.0";
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const args = { yolo: false, model: "", help: false, version: false };
|
|
14
|
+
for (const a of argv) {
|
|
15
|
+
if (a === "--yolo" || a === "-y")
|
|
16
|
+
args.yolo = true;
|
|
17
|
+
else if (a === "--help" || a === "-h")
|
|
18
|
+
args.help = true;
|
|
19
|
+
else if (a === "--version" || a === "-v")
|
|
20
|
+
args.version = true;
|
|
21
|
+
else if (a.startsWith("--model="))
|
|
22
|
+
args.model = a.slice("--model=".length);
|
|
23
|
+
}
|
|
24
|
+
return args;
|
|
25
|
+
}
|
|
26
|
+
function msg(err) {
|
|
27
|
+
return err instanceof Error ? err.message : String(err);
|
|
28
|
+
}
|
|
29
|
+
function printHelp() {
|
|
30
|
+
process.stdout.write(`polycode ${VERSION} — model-agnostic terminal coding agent
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
polycode [options]
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--model=<name> Start with a specific configured model
|
|
37
|
+
--yolo, -y Auto-approve all file writes and commands (use with care)
|
|
38
|
+
--version, -v Print version and exit
|
|
39
|
+
--help, -h Show this help
|
|
40
|
+
|
|
41
|
+
Config: ${globalConfigPath()}
|
|
42
|
+
|
|
43
|
+
Free open-source models (DeepSeek, GLM, Qwen, Llama, …):
|
|
44
|
+
• OpenRouter — get a FREE key (no credit card) at https://openrouter.ai/keys.
|
|
45
|
+
polycode will ask you to paste it on first run (or type /key anytime);
|
|
46
|
+
it's saved for next time. Run /model to browse all free models.
|
|
47
|
+
• Ollama — fully local & keyless: install https://ollama.com, then e.g.
|
|
48
|
+
\`ollama pull qwen3\` and pick a *-local model.
|
|
49
|
+
|
|
50
|
+
You can also set keys as env vars: OPENROUTER_API_KEY, OPENAI_API_KEY, etc.
|
|
51
|
+
|
|
52
|
+
Bring your own keys too: OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY.
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
function main() {
|
|
56
|
+
const args = parseArgs(process.argv.slice(2));
|
|
57
|
+
if (args.help)
|
|
58
|
+
return printHelp();
|
|
59
|
+
if (args.version)
|
|
60
|
+
return void process.stdout.write(`${VERSION}\n`);
|
|
61
|
+
const cwd = process.cwd();
|
|
62
|
+
applyStoredKeys(); // load any keys entered previously in the terminal
|
|
63
|
+
const { config, seeded } = loadConfig(cwd);
|
|
64
|
+
const broker = new PermissionBroker();
|
|
65
|
+
const tools = createTools(cwd, broker);
|
|
66
|
+
if (args.yolo) {
|
|
67
|
+
broker.allowAlways("write_file");
|
|
68
|
+
broker.allowAlways("edit_file");
|
|
69
|
+
broker.allowAlways("run_command");
|
|
70
|
+
}
|
|
71
|
+
const startName = args.model || config.defaultModel;
|
|
72
|
+
const startCfg = findModel(config, startName);
|
|
73
|
+
// Dynamic startup notices only — the title/help line lives in the banner.
|
|
74
|
+
const introLines = [];
|
|
75
|
+
if (seeded) {
|
|
76
|
+
introLines.push(`Seeded a default config at ${globalConfigPath()}.`);
|
|
77
|
+
}
|
|
78
|
+
// Does the starting model need a key we don't have yet? If so, we'll prompt
|
|
79
|
+
// for it right in the terminal instead of erroring out.
|
|
80
|
+
const startNeedsKey = !!startCfg && needsKey(startCfg) && !process.env[keyEnvFor(startCfg)];
|
|
81
|
+
let activeName = startName;
|
|
82
|
+
let initialModel = null;
|
|
83
|
+
let currentName = startName;
|
|
84
|
+
try {
|
|
85
|
+
if (!startCfg)
|
|
86
|
+
throw new ProviderError(`No model named "${startName}" in config.`);
|
|
87
|
+
initialModel = buildModel(startCfg);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
activeName = startCfg ? `${startName} (needs key)` : "none";
|
|
91
|
+
if (!startNeedsKey) {
|
|
92
|
+
introLines.push(msg(err));
|
|
93
|
+
introLines.push("Use /model to choose another, or /models to browse free models.");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const session = new AgentSession(initialModel, tools, cwd);
|
|
97
|
+
const switchModel = (name) => {
|
|
98
|
+
// Use a configured model if it exists; otherwise treat the name as a raw
|
|
99
|
+
// OpenRouter model id so any OpenRouter model works without editing config.
|
|
100
|
+
const cfg = findModel(config, name) ??
|
|
101
|
+
(process.env.OPENROUTER_API_KEY ? openRouterModelConfig(name) : undefined);
|
|
102
|
+
if (!cfg)
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
message: `No model named "${name}". Set OPENROUTER_API_KEY to use any OpenRouter model, or see /models.`,
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
session.setModel(buildModel(cfg));
|
|
109
|
+
currentName = name;
|
|
110
|
+
return { ok: true, message: `Switched to ${name}.`, name };
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
return { ok: false, message: msg(err) };
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
// Save a key typed in the terminal to the env var of the current model's
|
|
117
|
+
// provider, then rebuild that model so it's ready immediately.
|
|
118
|
+
const onSaveKey = (key) => {
|
|
119
|
+
const cfg = findModel(config, currentName) ?? openRouterModelConfig(currentName);
|
|
120
|
+
saveKey(keyEnvFor(cfg), key);
|
|
121
|
+
return switchModel(currentName);
|
|
122
|
+
};
|
|
123
|
+
render(_jsx(App, { session: session, broker: broker, config: config, initialModel: activeName, switchModel: switchModel, listModels: listModels, onSaveKey: onSaveKey, promptKeyOnStart: startNeedsKey, version: VERSION, intro: introLines.length ? introLines.join("\n") : undefined }));
|
|
124
|
+
}
|
|
125
|
+
main();
|