nou-cli 0.2.0 → 0.3.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/.env.example +12 -0
- package/README.md +42 -6
- package/dist/index.js +22 -2
- package/dist/src/agent/harness.js +54 -0
- package/dist/src/agent/index.js +159 -15
- package/dist/src/commands/index.js +54 -5
- package/dist/src/config/credentials.js +48 -0
- package/dist/src/config/index.js +3 -0
- package/dist/src/config/origins.js +14 -0
- package/dist/src/context/index.js +1 -1
- package/dist/src/engines/claude.js +147 -0
- package/dist/src/engines/codex.js +131 -0
- package/dist/src/engines/external.js +152 -0
- package/dist/src/engines/index.js +127 -0
- package/dist/src/engines/pi.js +161 -0
- package/dist/src/engines/types.js +1 -0
- package/dist/src/env.js +5 -1
- package/dist/src/git/index.js +14 -4
- package/dist/src/history/index.js +53 -1
- package/dist/src/plugins/index.js +109 -21
- package/dist/src/providers/anthropic.js +16 -1
- package/dist/src/providers/ollama.js +58 -1
- package/dist/src/providers/openai.js +22 -5
- package/dist/src/search/index.js +183 -0
- package/dist/src/setup/index.js +240 -0
- package/dist/src/tools/index.js +72 -2
- package/dist/src/ui/App.js +351 -72
- package/dist/src/ui/Chat.js +9 -3
- package/dist/src/ui/CodeView.js +5 -1
- package/dist/src/ui/Editor.js +5 -1
- package/dist/src/ui/FileTree.js +18 -1
- package/dist/src/ui/Header.js +3 -1
- package/dist/src/ui/ProblemsView.js +5 -1
- package/dist/src/ui/Settings.js +93 -14
- package/dist/src/ui/mouse.js +52 -0
- package/dist/src/ui/text.js +12 -0
- package/package.json +5 -3
- package/plugins/fetch-url.mjs +82 -0
- package/plugins/project-rules.mjs +43 -0
- package/plugins/run-tests.mjs +89 -0
- package/plugins/todos.mjs +81 -0
package/.env.example
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
# --- Local ---
|
|
8
8
|
# OLLAMA_HOST=http://127.0.0.1:11434
|
|
9
|
+
# Ollama context window. By default Nou asks for 32k tokens (or the model's max, if smaller),
|
|
10
|
+
# unless the model's Modelfile sets num_ctx. Lower it if you run out of memory.
|
|
11
|
+
# NOU_NUM_CTX=16384
|
|
12
|
+
# How much conversation (in characters, ~4 per token) Nou keeps before trimming and summarizing it.
|
|
13
|
+
# Defaults: 90000 for local models, 500000 for cloud ones. Lower it if a local model loses track.
|
|
14
|
+
# NOU_CONTEXT_CHARS=90000
|
|
9
15
|
# LMSTUDIO_BASE_URL=http://localhost:1234/v1
|
|
10
16
|
# LLAMACPP_BASE_URL=http://localhost:8080/v1
|
|
11
17
|
# Any other OpenAI-compatible server (vLLM, Jan, LocalAI, ...):
|
|
@@ -19,6 +25,12 @@
|
|
|
19
25
|
# OPENROUTER_API_KEY=
|
|
20
26
|
# GROQ_API_KEY=
|
|
21
27
|
# DEEPSEEK_API_KEY=
|
|
28
|
+
# FIREWORKS_API_KEY=
|
|
29
|
+
# A LiteLLM proxy, or any hosted OpenAI-compatible gateway (full cloud context, unlike NOU_BASE_URL):
|
|
30
|
+
# LITELLM_BASE_URL=http://localhost:4000/v1
|
|
31
|
+
# LITELLM_API_KEY=
|
|
32
|
+
# NOU_MODEL=litellm:<a model name from your proxy>
|
|
33
|
+
# Or skip this file: /connect <provider> inside Nou saves keys to ~/.nou/credentials.json.
|
|
22
34
|
|
|
23
35
|
# Claude effort level: low | medium | high | xhigh | max (default high)
|
|
24
36
|
# NOU_EFFORT=high
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<img src="https://img.shields.io/badge/Claude-cloud-bb9af7?style=flat-square&logo=anthropic&logoColor=white&labelColor=1a1b26" alt="Claude">
|
|
16
16
|
<img src="https://img.shields.io/badge/OpenAI-cloud-bb9af7?style=flat-square&labelColor=1a1b26" alt="OpenAI">
|
|
17
17
|
<img src="https://img.shields.io/badge/Gemini-cloud-bb9af7?style=flat-square&logo=googlegemini&logoColor=white&labelColor=1a1b26" alt="Gemini">
|
|
18
|
-
<img src="https://img.shields.io/badge/OpenRouter%20·%20Groq%20·%20DeepSeek-cloud-bb9af7?style=flat-square&labelColor=1a1b26" alt="More providers">
|
|
18
|
+
<img src="https://img.shields.io/badge/OpenRouter%20·%20Groq%20·%20DeepSeek%20·%20Fireworks%20·%20LiteLLM-cloud-bb9af7?style=flat-square&labelColor=1a1b26" alt="More providers">
|
|
19
19
|
</p>
|
|
20
20
|
|
|
21
21
|
<p align="center">
|
|
@@ -38,7 +38,7 @@ nou
|
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
> [!TIP]
|
|
41
|
-
> Want it fully local and free?
|
|
41
|
+
> Want it fully local and free? Just run `nou`: if Ollama or its model (`qwen3:8b`) is missing, Nou offers to install and download them. Already using Claude Code or Codex with an API key, or have Ollama models or LM Studio? Nou finds them on first run and lets you pick.
|
|
42
42
|
|
|
43
43
|
Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell, macOS/Linux terminals).
|
|
44
44
|
|
|
@@ -49,7 +49,9 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
|
|
|
49
49
|
| 🗂️ **File explorer** | Browse your project with file icons and git status (`M` modified, `?` new). Press <kbd>c</kbd> to see only changed files. |
|
|
50
50
|
| 🔀 **Live diffs** | Every edit Nou proposes shows up side by side. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
|
|
51
51
|
| ✍️ **Built-in editor** | Press <kbd>e</kbd> to edit the open file yourself, with undo/redo and **auto-save**. |
|
|
52
|
+
| 🔎 **Finds its way around** | The agent searches your code (`grep`, `glob`) without asking, reads only what it needs, runs your tests to check its work, and summarizes long conversations to stay within the model's context (`/compact` does it on demand). |
|
|
52
53
|
| ↶ **Undo anything** | Every change, Nou's or yours, is recorded. <kbd>u</kbd> undoes Nou's last edit, `/rewind` undoes its whole last turn, and your own later edits are kept. |
|
|
54
|
+
| 🧾 **Session diffs** | `/diff` shows everything that changed this session, file by file (<kbd>[</kbd> <kbd>]</kbd> to step through). `/sessions` lists earlier sessions and `/diff 2` shows one. |
|
|
53
55
|
| 🩺 **Problems, live** | Syntax errors instantly, plus ESLint, TypeScript, Ruff and language servers when your project has them. Shown in the gutter, inline, and in a <kbd>p</kbd> Problems panel. |
|
|
54
56
|
| 🤖 **Self-correcting** | Nou checks its own edits: you see "⚠ +1 problem" *before* approving, and any problems it introduced are fed back so it fixes them. |
|
|
55
57
|
| 🎨 **Real syntax highlighting** | tree-sitter parsing for 14 languages (TS, JS, Python, Go, Rust, Java, C, JSON, YAML…). |
|
|
@@ -57,6 +59,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
|
|
|
57
59
|
| 👀 **Knows what you're looking at** | The open file, visible lines, your cursor and unsaved edits ride along with each message. "Fix this function" just works. |
|
|
58
60
|
| 🌿 **Git aware** | Sees your branch, uncommitted changes and recent commits, and can diff on its own. Press <kbd>v</kbd> to diff any file against `HEAD`. |
|
|
59
61
|
| 🔌 **Any model** | Local (Ollama, LM Studio, llama.cpp) or cloud (Claude, OpenAI, Gemini, OpenRouter, Groq, DeepSeek). Switch mid-conversation. |
|
|
62
|
+
| 🛞 **Pick your agent** | Nou's own, or Claude Code, Codex or pi, switchable like models, with the same diffs, approvals and undo. |
|
|
60
63
|
| 🧩 **Plugins** | Add your own tools and slash commands with a single file. |
|
|
61
64
|
| 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> fuzzy-finds any file. |
|
|
62
65
|
|
|
@@ -73,12 +76,14 @@ Every change is reviewed like a code review:
|
|
|
73
76
|
|
|
74
77
|
| Key | Does |
|
|
75
78
|
| --- | --- |
|
|
79
|
+
| 🖱️ Mouse | wheel scrolls the pane under the pointer · click a pane to focus it · click a file to open it (hold <kbd>Shift</kbd> to select text; turn off in settings) |
|
|
80
|
+
| <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd>, <kbd>PgUp</kbd>/<kbd>PgDn</kbd> | scroll the chat history |
|
|
76
81
|
| <kbd>Tab</kbd> | switch between files, code and chat |
|
|
77
82
|
| <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file |
|
|
78
83
|
| <kbd>e</kbd> | edit the open file (<kbd>Ctrl</kbd>+<kbd>S</kbd> save · <kbd>Ctrl</kbd>+<kbd>Z</kbd>/<kbd>Y</kbd> undo/redo · <kbd>Ctrl</kbd>+<kbd>K</kbd> info · <kbd>Ctrl</kbd>+<kbd>G</kbd> definition · <kbd>Esc</kbd> close) |
|
|
79
84
|
| <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
|
|
80
85
|
| <kbd>u</kbd> | undo Nou's last edit |
|
|
81
|
-
| <kbd>v</kbd> | diff the open file vs git `HEAD
|
|
86
|
+
| <kbd>v</kbd> | diff the open file vs git `HEAD`, Nou's last edit, or this session · <kbd>n</kbd>/<kbd>N</kbd> jump between changes · <kbd>[</kbd>/<kbd>]</kbd> previous/next file in a session diff |
|
|
82
87
|
| <kbd>c</kbd> | in the file tree: show only changed files |
|
|
83
88
|
| <kbd>f</kbd> | follow mode: jump to files as Nou reads and edits them |
|
|
84
89
|
| <kbd>s</kbd> | settings: model, preferences, plugins |
|
|
@@ -86,11 +91,13 @@ Every change is reviewed like a code review:
|
|
|
86
91
|
| <kbd>Esc</kbd> | stop Nou · close a diff · back to chat |
|
|
87
92
|
| <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
|
|
88
93
|
|
|
89
|
-
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/undo` · `/rewind` · `/history` · `/problems` · `/clear` · `/help`
|
|
94
|
+
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/help`
|
|
90
95
|
|
|
91
96
|
## 🧠 Models
|
|
92
97
|
|
|
93
|
-
Pick one in <kbd>s</kbd> settings, with `/model anthropic` or `/model ollama:qwen3:8b`, or set `NOU_MODEL=provider:model`. API keys come from your environment or a `.env` file in your project; see [.env.example](.env.example).
|
|
98
|
+
Pick one in <kbd>s</kbd> settings, with `/model anthropic` or `/model ollama:qwen3:8b`, or set `NOU_MODEL=provider:model`. Switching to an Ollama model you don't have downloads it (or use `/pull <model>`). API keys come from your environment or a `.env` file in your project; see [.env.example](.env.example). Or connect a provider inside Nou: pick it in <kbd>s</kbd> settings and press <kbd>Enter</kbd>, or run `/connect fireworks`, then paste your key. It's saved to `~/.nou/credentials.json` (only you can read it) and works right away, no restart. If a key is rejected, settings says where it came from (your shell, `.env`, or Claude Code's or Codex's settings); press <kbd>k</kbd> to enter another.
|
|
99
|
+
|
|
100
|
+
If you haven't set a key, Nou uses the API key from Claude Code (`~/.claude/settings.json`) or Codex (`~/.codex/auth.json`). Subscription sign-ins (claude.ai, ChatGPT) can't be reused. Turn this off in settings with "Use Claude Code / Codex keys".
|
|
94
101
|
|
|
95
102
|
<details>
|
|
96
103
|
<summary><b>All providers and how to set them up</b></summary>
|
|
@@ -101,15 +108,34 @@ Pick one in <kbd>s</kbd> settings, with `/model anthropic` or `/model ollama:qwe
|
|
|
101
108
|
| 🖥️ LM Studio | 🟢 local | start its local server |
|
|
102
109
|
| ⚙️ llama.cpp server | 🟢 local | start `llama-server` |
|
|
103
110
|
| 🔧 Any OpenAI-compatible server | 🟢 local | `NOU_BASE_URL`, optional `NOU_API_KEY` |
|
|
111
|
+
| 🚅 LiteLLM proxy or any hosted gateway | ☁️ cloud | `/connect litellm`, or `LITELLM_BASE_URL` + `LITELLM_API_KEY` |
|
|
104
112
|
| 🟣 Anthropic (Claude) | ☁️ cloud | `ANTHROPIC_API_KEY` |
|
|
105
113
|
| ⚪ OpenAI | ☁️ cloud | `OPENAI_API_KEY` |
|
|
106
114
|
| 🔷 Google Gemini | ☁️ cloud | `GEMINI_API_KEY` |
|
|
107
115
|
| 🔀 OpenRouter | ☁️ cloud | `OPENROUTER_API_KEY` |
|
|
108
116
|
| ⚡ Groq | ☁️ cloud | `GROQ_API_KEY` |
|
|
109
117
|
| 🐋 DeepSeek | ☁️ cloud | `DEEPSEEK_API_KEY` |
|
|
118
|
+
| 🎆 Fireworks AI | ☁️ cloud | `/connect fireworks`, or `FIREWORKS_API_KEY` |
|
|
119
|
+
|
|
120
|
+
Every cloud provider can also be connected from inside Nou with `/connect <provider>`.
|
|
121
|
+
|
|
122
|
+
**Local or cloud?** Nou keeps less conversation for local models (about 90k characters) before summarizing, since they usually run with a small context window. The `custom` provider is meant for servers on your machine (vLLM, Jan, LocalAI...). For a LiteLLM proxy, or any other OpenAI-compatible gateway in front of cloud models, use `litellm`: it gets the full cloud budget. Override either with `NOU_CONTEXT_CHARS`.
|
|
110
123
|
|
|
111
124
|
</details>
|
|
112
125
|
|
|
126
|
+
## 🛞 Engines
|
|
127
|
+
|
|
128
|
+
The engine is the agent that answers in the chat. Nou's own works with every model; you can switch to another one any time, like switching models: <kbd>s</kbd> settings → **Engine**, or `/engine <name>`. Diffs, approvals, undo and the problems panel work with all of them.
|
|
129
|
+
|
|
130
|
+
| Engine | Models | Asks before edits and commands |
|
|
131
|
+
|:--|:--|:--:|
|
|
132
|
+
| `nou` *(default)* | any | ✅ |
|
|
133
|
+
| `claude-code` (Claude Agent SDK) | Claude; your API key or Claude login | ✅ |
|
|
134
|
+
| `pi` | your Nou model, local or cloud | ✅ |
|
|
135
|
+
| `codex` (Codex SDK) | OpenAI; your API key or ChatGPT login | ❌ edits in its sandbox; review and undo after |
|
|
136
|
+
|
|
137
|
+
Other engines install on first use into `~/.nou/engines/`, so Nou itself stays small. Switching engines starts a new conversation. Compare them on the same tasks with `npm run eval -- --engine nou --engine claude-code`.
|
|
138
|
+
|
|
113
139
|
## 🔄 Updating
|
|
114
140
|
|
|
115
141
|
Nou checks npm once a day and offers to update when a new version is out (one keypress, then it restarts itself). Or run it yourself:
|
|
@@ -123,7 +149,16 @@ Set `NOU_NO_UPDATE_CHECK=1` to turn the check off.
|
|
|
123
149
|
|
|
124
150
|
## 🧩 Plugins *(early)*
|
|
125
151
|
|
|
126
|
-
|
|
152
|
+
**Starter plugins** ship with Nou and are optional: open <kbd>s</kbd> settings → **Plugins** and press Enter on one, or type `/plugins add <name>`. Adding one copies it into `~/.nou/plugins/`, where it's yours to edit.
|
|
153
|
+
|
|
154
|
+
| Starter | What it adds |
|
|
155
|
+
|:--|:--|
|
|
156
|
+
| `run-tests` | `run_tests` tool and `/test`. Works out npm / pnpm / yarn / bun, cargo, go, pytest or make (asks before each run). |
|
|
157
|
+
| `todos` | `find_todos` tool and `/todos`: TODO / FIXME / HACK comments with file:line. |
|
|
158
|
+
| `fetch-url` | `fetch_url` tool so the model can read docs and web pages (asks before each URL). |
|
|
159
|
+
| `project-rules` | Feeds `AGENTS.md`, `CLAUDE.md`, `.cursorrules` and Copilot instructions to the model; `/rules` lists them. |
|
|
160
|
+
|
|
161
|
+
**Write your own:** `/plugins new <name>` creates a commented `.nou/plugins/<name>.mjs` to fill in. `/plugins` lists what you have. Or drop a `.js` / `.mjs` file into `.nou/plugins/` (this project) or `~/.nou/plugins/` (everywhere), then switch it on in <kbd>s</kbd> settings → **Plugins**. A plugin can give the model new tools, add slash commands, add to the system prompt, and add **checkers** (linters) whose problems show in the editor. The built-in ESLint, TypeScript, Ruff, syntax and language-server checkers are plugins too, so you can switch any of them off.
|
|
127
162
|
|
|
128
163
|
```js
|
|
129
164
|
export default {
|
|
@@ -151,6 +186,7 @@ npm run dev # run from source
|
|
|
151
186
|
npm run build # compile to dist/ (and copy the syntax grammars)
|
|
152
187
|
npm test # end-to-end tests: drives the real UI with simulated keys
|
|
153
188
|
npm run screenshot # regenerate docs/screenshot.svg
|
|
189
|
+
npm run eval # how well the agent does real tasks with a real model (see evals/)
|
|
154
190
|
```
|
|
155
191
|
|
|
156
192
|
Releasing: bump `version` in `package.json`, merge to `master`, then run **Actions → Publish to npm**. It tests, publishes with provenance, and creates the git tag and GitHub Release. (Or publish a GitHub release tagged `vX.Y.Z`.) The token lives in the `production` environment as `NPM_TOKEN`.
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,10 @@ import * as p from "@clack/prompts";
|
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import { artWork } from "./src/util/index.js";
|
|
9
9
|
import { ensureProjectContext, loadPermanentContext } from "./src/context/index.js";
|
|
10
|
-
import { describe, initialModel, resolveModel } from "./src/providers/index.js";
|
|
10
|
+
import { describe, initialModel, resolveModel, saveModel } from "./src/providers/index.js";
|
|
11
|
+
import { loadSettings } from "./src/config/index.js";
|
|
12
|
+
import { loadCredentials } from "./src/config/credentials.js";
|
|
13
|
+
import { borrowToolConfig, ensureLocalModel, pickStartingModel } from "./src/setup/index.js";
|
|
11
14
|
import { checkForUpdate, currentVersion, installUpdate, relaunch } from "./src/update/index.js";
|
|
12
15
|
import { lsp } from "./src/lsp/index.js";
|
|
13
16
|
import { App } from "./src/ui/App.js";
|
|
@@ -19,7 +22,7 @@ Usage:
|
|
|
19
22
|
nou --version print the version
|
|
20
23
|
nou --help show this help
|
|
21
24
|
|
|
22
|
-
Settings live in ~/.nou/config.json (or press s inside Nou). Set NOU_NO_UPDATE_CHECK=1 to skip update checks.`;
|
|
25
|
+
Settings live in ~/.nou/config.json (or press s inside Nou); keys you enter there, or with /connect, in ~/.nou/credentials.json. Set NOU_NO_UPDATE_CHECK=1 to skip update checks.`;
|
|
23
26
|
async function main() {
|
|
24
27
|
const args = process.argv.slice(2);
|
|
25
28
|
if (args.includes("--version") || args.includes("-v")) {
|
|
@@ -63,12 +66,29 @@ async function main() {
|
|
|
63
66
|
: "The update failed. If it was a permissions error, run: sudo npm install -g nou-cli@latest");
|
|
64
67
|
}
|
|
65
68
|
}
|
|
69
|
+
const settings = loadSettings();
|
|
70
|
+
loadCredentials(); // keys entered inside Nou, for whatever the environment and .env leave unset
|
|
71
|
+
const borrowed = settings.reuseToolConfig ? borrowToolConfig() : [];
|
|
72
|
+
for (const b of borrowed)
|
|
73
|
+
p.log.info(`Using the ${b.provider === "anthropic" ? "Anthropic" : "OpenAI"} API key from your ${b.tool} settings.`);
|
|
66
74
|
let choice = initialModel();
|
|
75
|
+
// First run: start with a model that's already here, if there is one.
|
|
76
|
+
if (!process.env.NOU_MODEL && !settings.model) {
|
|
77
|
+
const picked = await pickStartingModel(borrowed);
|
|
78
|
+
if (picked) {
|
|
79
|
+
choice = picked;
|
|
80
|
+
saveModel(picked);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
67
83
|
const problem = choice.provider.unavailable();
|
|
68
84
|
if (problem) {
|
|
69
85
|
p.log.warn(`${choice.provider.label} isn't set up (${problem}), falling back to local Ollama.`);
|
|
70
86
|
choice = resolveModel("ollama");
|
|
71
87
|
}
|
|
88
|
+
const ready = await ensureLocalModel(choice);
|
|
89
|
+
if (ready !== choice)
|
|
90
|
+
saveModel(ready); // picked a model that's already installed instead
|
|
91
|
+
choice = ready;
|
|
72
92
|
p.log.info(`Model: ${chalk.yellow(describe(choice))} (change it any time with /model)`);
|
|
73
93
|
await ensureProjectContext(choice);
|
|
74
94
|
const permContext = await loadPermanentContext();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { formatDiagnostic } from "../diagnostics/index.js";
|
|
2
|
+
import { summary as gitSummary } from "../git/index.js";
|
|
3
|
+
import { gitTools } from "../git/tools.js";
|
|
4
|
+
/**
|
|
5
|
+
* Everything the agent gets on top of the built-in tools: git context and tools, plugin prompts and tools,
|
|
6
|
+
* and the problems checker. Shared by the app and the evals, so evals measure exactly what users get.
|
|
7
|
+
*/
|
|
8
|
+
export function harness(options) {
|
|
9
|
+
const { diagnostics, plugins, git } = options;
|
|
10
|
+
const extras = [];
|
|
11
|
+
const extraTools = [];
|
|
12
|
+
if (git) {
|
|
13
|
+
const summary = gitSummary();
|
|
14
|
+
if (summary)
|
|
15
|
+
extras.push(summary);
|
|
16
|
+
extraTools.push(...gitTools());
|
|
17
|
+
}
|
|
18
|
+
for (const plugin of plugins) {
|
|
19
|
+
if (plugin.systemPrompt)
|
|
20
|
+
extras.push(`Plugin ${plugin.name}: ${plugin.systemPrompt}`);
|
|
21
|
+
for (const tool of plugin.tools ?? []) {
|
|
22
|
+
extraTools.push({
|
|
23
|
+
definition: tool.definition,
|
|
24
|
+
readOnly: !!tool.readOnly,
|
|
25
|
+
source: plugin.name,
|
|
26
|
+
run: (args) => tool.run(args, { cwd: process.cwd() }),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
extraTools.push({
|
|
31
|
+
source: "problems",
|
|
32
|
+
readOnly: true,
|
|
33
|
+
definition: {
|
|
34
|
+
name: "get_diagnostics",
|
|
35
|
+
description: "Get errors and warnings from the project's linters, type checker and language servers, for one file or the whole project.",
|
|
36
|
+
parameters: { type: "object", properties: { path: { type: "string", description: "Optional file. Omit for the whole project." } }, required: [] },
|
|
37
|
+
},
|
|
38
|
+
run: async (args) => {
|
|
39
|
+
const file = typeof args.path === "string" ? args.path : null;
|
|
40
|
+
if (file)
|
|
41
|
+
await diagnostics.checkFile(file);
|
|
42
|
+
else
|
|
43
|
+
await diagnostics.checkProject();
|
|
44
|
+
const list = file ? diagnostics.get(file) : diagnostics.all();
|
|
45
|
+
return list.length ? list.slice(0, 100).map((d) => formatDiagnostic(d, true)).join("\n") : "No problems found.";
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return { extras, extraTools };
|
|
49
|
+
}
|
|
50
|
+
/** Problems an applied edit introduced, to feed back to the model (the agent's checkChange hook). */
|
|
51
|
+
export async function problemsIntroduced(diagnostics, change) {
|
|
52
|
+
const introduced = await diagnostics.introducedBy(change.path, change.before, change.after);
|
|
53
|
+
return introduced.length ? introduced.slice(0, 15).map((d) => formatDiagnostic(d)).join("\n") : null;
|
|
54
|
+
}
|
package/dist/src/agent/index.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
1
2
|
import { toolDefs } from "../tools/index.js";
|
|
2
|
-
import { applyChange, fileTree, proposeEdit, proposeWrite,
|
|
3
|
+
import { applyChange, capOutput, fileTree, isInProject, proposeEdit, proposeWrite, readForModel, runCommand, toRelative, } from "../commands/index.js";
|
|
3
4
|
import { describe } from "../providers/index.js";
|
|
4
5
|
import { newCallId } from "../providers/ids.js";
|
|
5
|
-
|
|
6
|
+
import { globFiles, grep } from "../search/index.js";
|
|
7
|
+
/** Model requests per user message before stopping; real tasks (explore, edit, test, fix) take a few dozen. */
|
|
8
|
+
const MAX_STEPS = 60;
|
|
9
|
+
/** Messages kept word for word when older ones are summarized. */
|
|
10
|
+
const KEEP_RECENT = 8;
|
|
11
|
+
const TRIMMED_OUTPUT = "\n[... trimmed to save context; run the tool again if you need it]";
|
|
12
|
+
/** Unique per conversation, since turn numbers restart at 1 every session. */
|
|
13
|
+
export const newSessionId = () => `${Date.now().toString(36)}${randomUUID().slice(0, 8)}`;
|
|
14
|
+
/** How many lines of the project tree go in the system prompt; the model can glob or list_files for more. */
|
|
15
|
+
const PROMPT_TREE_LINES = 150;
|
|
6
16
|
export function buildSystemPrompt(permContext, extras = []) {
|
|
7
|
-
|
|
8
|
-
You
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
const shell = process.platform === "win32" ? "Windows (commands run in cmd.exe)" : `${process.platform} (commands run in sh)`;
|
|
18
|
+
return `You are Nou, a coding agent in the user's terminal. You work in ${process.cwd()} on ${shell}.
|
|
19
|
+
|
|
20
|
+
How to work:
|
|
21
|
+
1. Look before you change anything. Use grep to find where things are defined and used, glob to find files by name, and read_file for the parts you need. Never guess what a file contains.
|
|
22
|
+
2. Make the smallest change that does what was asked, in the style of the surrounding code. Use edit_file for changes, copying old_string exactly from what you read (replace_all renames every occurrence in a file). Use write_file only for new files or complete rewrites.
|
|
23
|
+
3. Check your work. If the project has tests, a build or a type check, run them with run_command and fix what you broke. If it has none, try new code on the examples the user gave (a one-line script is enough). Problems the editor finds after an edit are reported back to you: fix them.
|
|
24
|
+
4. Keep going until the task is done. The user approves each edit and command as it happens, so don't stop to ask permission between steps; ask only when the request is unclear or you need a decision.
|
|
25
|
+
5. End with a short summary of what you changed and why. When the user only asks a question, answer it without editing anything.
|
|
26
|
+
|
|
27
|
+
- If the user rejects an edit or command, don't retry it: ask what they would like instead.
|
|
28
|
+
- Keep replies short. Refer to code as path:line.
|
|
12
29
|
|
|
13
30
|
Project files:
|
|
14
|
-
${fileTree(".", 3)}
|
|
31
|
+
${fileTree(".", 3, PROMPT_TREE_LINES)}
|
|
15
32
|
${extras.map((e) => `\n${e}`).join("")}
|
|
16
33
|
${permContext}`;
|
|
17
34
|
}
|
|
@@ -25,10 +42,12 @@ function invalidArgs(call, tools) {
|
|
|
25
42
|
const missing = def.parameters.required.filter((key) => call.args[key] === undefined || call.args[key] === null);
|
|
26
43
|
return missing.length ? `Missing or non-string arguments: ${missing.join(", ")}` : null;
|
|
27
44
|
}
|
|
45
|
+
/** Nou's own engine: its tools, prompt and loop, with any model. */
|
|
28
46
|
export class Agent {
|
|
29
47
|
choice;
|
|
30
48
|
system;
|
|
31
49
|
hooks;
|
|
50
|
+
id = "nou";
|
|
32
51
|
messages = [];
|
|
33
52
|
controller = null;
|
|
34
53
|
extraTools = [];
|
|
@@ -37,7 +56,7 @@ export class Agent {
|
|
|
37
56
|
/** Increments per user message; tags changes so a whole turn can be rewound. */
|
|
38
57
|
turn = 0;
|
|
39
58
|
/** Distinguishes this run from earlier ones, since turn numbers restart at 1 every session. */
|
|
40
|
-
session =
|
|
59
|
+
session = newSessionId();
|
|
41
60
|
get turnId() {
|
|
42
61
|
return `${this.session}:${this.turn}`;
|
|
43
62
|
}
|
|
@@ -72,6 +91,104 @@ export class Agent {
|
|
|
72
91
|
cancel() {
|
|
73
92
|
this.controller?.abort();
|
|
74
93
|
}
|
|
94
|
+
/** Roughly what each request sends, in characters (about 4 per token): prompt, tool schemas and history. */
|
|
95
|
+
requestSize() {
|
|
96
|
+
return this.system.length + JSON.stringify(this.tools).length + JSON.stringify(this.messages).length;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* How much context to stay under, in characters. Local models get a 32k-token window from Nou (see
|
|
100
|
+
* providers/ollama.ts), so ~90k characters leaves room for the reply; cloud models have far more.
|
|
101
|
+
*/
|
|
102
|
+
contextBudget() {
|
|
103
|
+
const set = Number(process.env.NOU_CONTEXT_CHARS);
|
|
104
|
+
if (set > 0)
|
|
105
|
+
return set;
|
|
106
|
+
return this.choice.provider.local ? 90_000 : 500_000;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Makes the conversation fit the model's context: first trims old tool output (the bulk of it, and
|
|
110
|
+
* rarely needed again), then, if that isn't enough, has the model summarize older messages. `force`
|
|
111
|
+
* (the /compact command) summarizes even when it fits. Returns whether anything changed.
|
|
112
|
+
*/
|
|
113
|
+
async compact(force = false, signal) {
|
|
114
|
+
const budget = this.contextBudget();
|
|
115
|
+
const startSize = this.requestSize();
|
|
116
|
+
// The latest step's results are what the model is working from; it has already used the older ones.
|
|
117
|
+
const lastStep = this.messages.findLastIndex((m) => m.role === "assistant");
|
|
118
|
+
let trimmed = 0;
|
|
119
|
+
for (let i = 0; i < lastStep; i++) {
|
|
120
|
+
const m = this.messages[i];
|
|
121
|
+
if (m.role === "tool" && m.content.length > TRIMMED_OUTPUT.length + 400) {
|
|
122
|
+
this.messages[i] = { ...m, content: `${m.content.slice(0, 300)}${TRIMMED_OUTPUT}` };
|
|
123
|
+
trimmed++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!force && this.requestSize() <= budget * 0.8) {
|
|
127
|
+
this.hooks.onEntry({ kind: "info", text: `Trimmed ${trimmed} old tool outputs to fit the model's context.` });
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
const split = this.summarySplit();
|
|
131
|
+
if (split <= 0)
|
|
132
|
+
return trimmed > 0;
|
|
133
|
+
this.hooks.onBusy("Summarizing the conversation to fit the model's context...");
|
|
134
|
+
let summary;
|
|
135
|
+
try {
|
|
136
|
+
summary = await this.summarize(this.messages.slice(0, split), signal);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (signal?.aborted)
|
|
140
|
+
throw error;
|
|
141
|
+
summary = `(The earlier conversation was dropped to fit the context; summarizing it failed: ${error.message})`;
|
|
142
|
+
}
|
|
143
|
+
const intro = `[Summary of the conversation so far]\n${summary}`;
|
|
144
|
+
const rest = this.messages.slice(split);
|
|
145
|
+
const first = rest[0];
|
|
146
|
+
this.messages = first?.role === "user"
|
|
147
|
+
? [{ ...first, content: `${intro}\n\n${first.content}` }, ...rest.slice(1)]
|
|
148
|
+
: [{ role: "user", content: intro }, ...rest];
|
|
149
|
+
this.hooks.onEntry({
|
|
150
|
+
kind: "info",
|
|
151
|
+
text: `Summarized ${split} earlier messages to fit the model's context (${Math.round(startSize / 1000)}k → ${Math.round(this.requestSize() / 1000)}k characters).`,
|
|
152
|
+
});
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Where to cut the history for a summary: as late as possible while keeping the last few messages, and
|
|
157
|
+
* only before a user or assistant message, so no tool call is separated from its result.
|
|
158
|
+
*/
|
|
159
|
+
summarySplit() {
|
|
160
|
+
for (let i = this.messages.length - KEEP_RECENT; i > 0; i--) {
|
|
161
|
+
if (this.messages[i]?.role !== "tool")
|
|
162
|
+
return i;
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
async summarize(older, signal) {
|
|
167
|
+
const clip = (text, max) => (text.length > max ? `${text.slice(0, max)}...` : text);
|
|
168
|
+
const transcript = older.map((m) => {
|
|
169
|
+
if (m.role === "user")
|
|
170
|
+
return `USER: ${clip(m.content, 4000)}`;
|
|
171
|
+
if (m.role === "tool")
|
|
172
|
+
return `TOOL RESULT (${m.name}): ${clip(m.content, 600)}`;
|
|
173
|
+
const calls = m.toolCalls.map((c) => `${c.name}(${clip(JSON.stringify(c.args), 300)})`).join(", ");
|
|
174
|
+
return `ASSISTANT: ${clip(m.content, 2000)}${calls ? `\n called: ${calls}` : ""}`;
|
|
175
|
+
}).join("\n\n");
|
|
176
|
+
// Keep the request itself within budget: the latest part of the history matters most.
|
|
177
|
+
const room = Math.floor(this.contextBudget() * 0.7);
|
|
178
|
+
const text = transcript.length > room ? `[...]\n${transcript.slice(-room)}` : transcript;
|
|
179
|
+
const result = await this.choice.provider.chat({
|
|
180
|
+
model: this.choice.model,
|
|
181
|
+
system: "You summarize a coding session so it can continue with less context. Be factual and concise.",
|
|
182
|
+
messages: [{
|
|
183
|
+
role: "user",
|
|
184
|
+
content: `Summarize this conversation between a user and a coding agent, for the agent to continue from. Include: the user's requests (quote the latest one exactly), decisions made, files read or changed and what changed, commands run and their outcome, and what is left to do.\n\n${text}`,
|
|
185
|
+
}],
|
|
186
|
+
tools: [],
|
|
187
|
+
onText: () => { },
|
|
188
|
+
...(signal ? { signal } : {}),
|
|
189
|
+
});
|
|
190
|
+
return result.text.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim() || "(no summary)";
|
|
191
|
+
}
|
|
75
192
|
/** `context` (what the user is looking at) is sent to the model but not shown in the chat. */
|
|
76
193
|
async send(text, context) {
|
|
77
194
|
this.turn++;
|
|
@@ -82,6 +199,8 @@ export class Agent {
|
|
|
82
199
|
const { signal } = this.controller;
|
|
83
200
|
try {
|
|
84
201
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
202
|
+
if (this.requestSize() > this.contextBudget())
|
|
203
|
+
await this.compact(false, signal);
|
|
85
204
|
this.hooks.onBusy(`Pondering with ${describe(this.choice)}...`);
|
|
86
205
|
let streamed = "";
|
|
87
206
|
const result = await this.choice.provider.chat({
|
|
@@ -108,7 +227,8 @@ export class Agent {
|
|
|
108
227
|
}
|
|
109
228
|
this.messages.push({
|
|
110
229
|
role: "assistant",
|
|
111
|
-
|
|
230
|
+
// Local reasoning models think in <think> blocks; sending those back only uses up context.
|
|
231
|
+
content: result.raw ? result.text : result.text.replace(/<think>[\s\S]*?<\/think>\s*/g, ""),
|
|
112
232
|
toolCalls: result.toolCalls,
|
|
113
233
|
...(result.raw ? { raw: result.raw } : {}),
|
|
114
234
|
});
|
|
@@ -132,7 +252,7 @@ export class Agent {
|
|
|
132
252
|
break;
|
|
133
253
|
this.hooks.onBusy(`Running ${call.name}...`);
|
|
134
254
|
const problem = invalidArgs(call, this.tools);
|
|
135
|
-
const output = problem ?? await this.runTool(call);
|
|
255
|
+
const output = problem ?? capOutput(await this.runTool(call));
|
|
136
256
|
this.messages.push({
|
|
137
257
|
role: "tool",
|
|
138
258
|
callId: call.id,
|
|
@@ -180,19 +300,43 @@ export class Agent {
|
|
|
180
300
|
try {
|
|
181
301
|
switch (name) {
|
|
182
302
|
case "read_file": {
|
|
183
|
-
|
|
303
|
+
// Files outside the project (e.g. ~/.ssh) need your OK, like commands do.
|
|
304
|
+
if (!isInProject(args.path)) {
|
|
305
|
+
if (!(await this.hooks.approve({ kind: "tool", name, args }))) {
|
|
306
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: read ${args.path}` });
|
|
307
|
+
return "User denied reading this file: it is outside the project folder.";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
this.hooks.onRead(toRelative(args.path));
|
|
312
|
+
}
|
|
184
313
|
this.hooks.onEntry({ kind: "tool", text: `Read ${args.path}` });
|
|
185
|
-
return await
|
|
314
|
+
return await readForModel(args.path, args.offset, args.limit);
|
|
186
315
|
}
|
|
187
316
|
case "list_files":
|
|
188
317
|
case "get_file_tree": {
|
|
189
318
|
this.hooks.onEntry({ kind: "tool", text: `Listed ${args.path ?? "."}` });
|
|
190
|
-
return fileTree(args.path ?? ".", 4);
|
|
319
|
+
return fileTree(args.path ?? ".", 4, 500);
|
|
320
|
+
}
|
|
321
|
+
case "grep": {
|
|
322
|
+
this.hooks.onEntry({ kind: "tool", text: `Searched for ${args.pattern}${args.path ? ` in ${args.path}` : ""}${args.glob ? ` (${args.glob})` : ""}` });
|
|
323
|
+
return grep({
|
|
324
|
+
pattern: String(args.pattern),
|
|
325
|
+
...(typeof args.path === "string" ? { path: args.path } : {}),
|
|
326
|
+
...(typeof args.glob === "string" ? { glob: args.glob } : {}),
|
|
327
|
+
ignoreCase: args.ignore_case === true,
|
|
328
|
+
context: Number(args.context) || 0,
|
|
329
|
+
filesOnly: args.files_only === true,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
case "glob": {
|
|
333
|
+
this.hooks.onEntry({ kind: "tool", text: `Found files ${args.pattern}` });
|
|
334
|
+
return globFiles(String(args.pattern), typeof args.path === "string" ? args.path : ".");
|
|
191
335
|
}
|
|
192
336
|
case "edit_file":
|
|
193
337
|
case "write_file": {
|
|
194
338
|
const change = name === "edit_file"
|
|
195
|
-
? await proposeEdit(args.path, args.old_string, args.new_string)
|
|
339
|
+
? await proposeEdit(args.path, args.old_string, args.new_string, args.replace_all === true)
|
|
196
340
|
: await proposeWrite(args.path, args.content ?? "");
|
|
197
341
|
return await this.reviewChange(change);
|
|
198
342
|
}
|
|
@@ -3,6 +3,11 @@ import fs from "fs/promises";
|
|
|
3
3
|
import { readdirSync } from "fs";
|
|
4
4
|
import { exec } from "child_process";
|
|
5
5
|
export const IGNORED = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
6
|
+
/** Whether a path is inside the project folder (where Nou may read without asking and write at all). */
|
|
7
|
+
export function isInProject(filePath) {
|
|
8
|
+
const rel = path.relative(process.cwd(), path.resolve(process.cwd(), filePath));
|
|
9
|
+
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
10
|
+
}
|
|
6
11
|
function resolveInProject(filePath) {
|
|
7
12
|
const fullPath = path.resolve(process.cwd(), filePath);
|
|
8
13
|
const rel = path.relative(process.cwd(), fullPath);
|
|
@@ -25,6 +30,41 @@ export async function readFile(filepath) {
|
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
;
|
|
33
|
+
/** Lines read_file returns by default, and the most characters it returns at once. */
|
|
34
|
+
export const READ_LINES = 1000;
|
|
35
|
+
const READ_CHARS = 40_000;
|
|
36
|
+
/**
|
|
37
|
+
* read_file for the model: a window of lines (1-based `offset`), with a note saying how to read on when the
|
|
38
|
+
* file is longer, so a big file can't flood the model's context.
|
|
39
|
+
*/
|
|
40
|
+
export async function readForModel(filePath, offset = 1, limit = READ_LINES) {
|
|
41
|
+
const text = await fs.readFile(path.resolve(process.cwd(), filePath), "utf-8");
|
|
42
|
+
const lines = text.split(/\r?\n/);
|
|
43
|
+
if (lines.length > 1 && lines.at(-1) === "")
|
|
44
|
+
lines.pop();
|
|
45
|
+
const start = Math.max(1, Math.floor(offset) || 1);
|
|
46
|
+
if (start > lines.length)
|
|
47
|
+
return `(The file has ${lines.length} lines.)`;
|
|
48
|
+
let end = Math.min(lines.length, start + Math.max(1, Math.floor(limit) || READ_LINES) - 1);
|
|
49
|
+
if (start === 1 && end === lines.length && text.length <= READ_CHARS)
|
|
50
|
+
return text;
|
|
51
|
+
let body = lines.slice(start - 1, end).join("\n");
|
|
52
|
+
if (body.length > READ_CHARS) {
|
|
53
|
+
body = body.slice(0, READ_CHARS);
|
|
54
|
+
end = start + body.split("\n").length - 1;
|
|
55
|
+
body = `${body}\n[... line ${end} cut off: it is very long]`;
|
|
56
|
+
}
|
|
57
|
+
const more = end < lines.length ? ` Read more with offset=${end + 1}.` : "";
|
|
58
|
+
return `${body}\n\n[Lines ${start}-${end} of ${lines.length}.${more}]`;
|
|
59
|
+
}
|
|
60
|
+
/** Keeps tool output a sensible size for the model: the start and, where errors usually are, the end. */
|
|
61
|
+
export function capOutput(text, max = 40_000) {
|
|
62
|
+
if (text.length <= max)
|
|
63
|
+
return text;
|
|
64
|
+
const head = Math.floor(max / 3);
|
|
65
|
+
const tail = max - head;
|
|
66
|
+
return `${text.slice(0, head)}\n\n[... ${text.length - max} characters cut ...]\n\n${text.slice(-tail)}`;
|
|
67
|
+
}
|
|
28
68
|
export function runCommand(command) {
|
|
29
69
|
// Async so the UI keeps rendering while the command runs.
|
|
30
70
|
return new Promise((resolve) => {
|
|
@@ -40,8 +80,9 @@ ${stdout}` : ""}`);
|
|
|
40
80
|
});
|
|
41
81
|
});
|
|
42
82
|
}
|
|
43
|
-
export function fileTree(dir = ".", maxDepth = 3) {
|
|
83
|
+
export function fileTree(dir = ".", maxDepth = 3, maxLines = Infinity) {
|
|
44
84
|
const lines = [];
|
|
85
|
+
let hidden = 0;
|
|
45
86
|
const walk = (current, depth, prefix) => {
|
|
46
87
|
if (depth > maxDepth)
|
|
47
88
|
return;
|
|
@@ -55,12 +96,18 @@ export function fileTree(dir = ".", maxDepth = 3) {
|
|
|
55
96
|
return;
|
|
56
97
|
}
|
|
57
98
|
for (const entry of entries) {
|
|
99
|
+
if (lines.length >= maxLines) {
|
|
100
|
+
hidden++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
58
103
|
lines.push(`${prefix}${entry.name}${entry.isDirectory() ? "/" : ""}`);
|
|
59
104
|
if (entry.isDirectory())
|
|
60
105
|
walk(path.join(current, entry.name), depth + 1, prefix + " ");
|
|
61
106
|
}
|
|
62
107
|
};
|
|
63
108
|
walk(path.resolve(process.cwd(), dir), 1, "");
|
|
109
|
+
if (hidden)
|
|
110
|
+
lines.push("[... more entries not shown; use glob, grep or list_files on a folder]");
|
|
64
111
|
return lines.join("\n") || "(empty)";
|
|
65
112
|
}
|
|
66
113
|
/**
|
|
@@ -95,7 +142,7 @@ export async function proposeWrite(filePath, content) {
|
|
|
95
142
|
return { path: toRelative(filePath), before: before ?? "", after: cleanContent(content), isNew: before === null };
|
|
96
143
|
}
|
|
97
144
|
/** Works out what edit_file would do, without touching the disk. */
|
|
98
|
-
export async function proposeEdit(filePath, oldString, newString) {
|
|
145
|
+
export async function proposeEdit(filePath, oldString, newString, replaceAll = false) {
|
|
99
146
|
const fullPath = resolveInProject(filePath);
|
|
100
147
|
const before = await readOrEmpty(fullPath);
|
|
101
148
|
if (before === null)
|
|
@@ -107,9 +154,11 @@ export async function proposeEdit(filePath, oldString, newString) {
|
|
|
107
154
|
const count = target ? normalized.split(target).length - 1 : 0;
|
|
108
155
|
if (count === 0)
|
|
109
156
|
throw new Error("old_string was not found in the file. Read the file and copy the text exactly.");
|
|
110
|
-
if (count > 1)
|
|
111
|
-
throw new Error(`old_string appears ${count} times. Include more surrounding lines so it is unique.`);
|
|
112
|
-
|
|
157
|
+
if (count > 1 && !replaceAll) {
|
|
158
|
+
throw new Error(`old_string appears ${count} times. Include more surrounding lines so it is unique, or set replace_all to change every one.`);
|
|
159
|
+
}
|
|
160
|
+
const replacement = newString.replace(/\r\n/g, "\n");
|
|
161
|
+
const after = (replaceAll ? normalized.split(target).join(replacement) : normalized.replace(target, () => replacement)).replace(/\n/g, eol);
|
|
113
162
|
return { path: toRelative(filePath), before, after, isNew: false };
|
|
114
163
|
}
|
|
115
164
|
export async function applyChange(change) {
|