nou-cli 0.2.1 → 0.4.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.
Files changed (42) hide show
  1. package/.env.example +13 -0
  2. package/README.md +47 -10
  3. package/dist/index.js +22 -2
  4. package/dist/src/agent/harness.js +54 -0
  5. package/dist/src/agent/index.js +162 -17
  6. package/dist/src/commands/index.js +54 -5
  7. package/dist/src/config/credentials.js +48 -0
  8. package/dist/src/config/index.js +4 -1
  9. package/dist/src/config/origins.js +14 -0
  10. package/dist/src/context/index.js +1 -1
  11. package/dist/src/engines/claude.js +147 -0
  12. package/dist/src/engines/codex.js +131 -0
  13. package/dist/src/engines/external.js +152 -0
  14. package/dist/src/engines/index.js +127 -0
  15. package/dist/src/engines/pi.js +161 -0
  16. package/dist/src/engines/types.js +1 -0
  17. package/dist/src/env.js +5 -1
  18. package/dist/src/git/index.js +14 -4
  19. package/dist/src/history/index.js +53 -1
  20. package/dist/src/plugins/index.js +109 -21
  21. package/dist/src/providers/anthropic.js +16 -1
  22. package/dist/src/providers/index.js +16 -0
  23. package/dist/src/providers/ollama.js +58 -1
  24. package/dist/src/providers/openai.js +26 -6
  25. package/dist/src/search/index.js +183 -0
  26. package/dist/src/setup/index.js +240 -0
  27. package/dist/src/syntax/index.js +11 -2
  28. package/dist/src/tools/index.js +72 -2
  29. package/dist/src/ui/App.js +452 -93
  30. package/dist/src/ui/Chat.js +19 -10
  31. package/dist/src/ui/CodeView.js +1 -1
  32. package/dist/src/ui/Editor.js +1 -1
  33. package/dist/src/ui/FileTree.js +12 -7
  34. package/dist/src/ui/Settings.js +91 -15
  35. package/dist/src/ui/clipboard.js +29 -0
  36. package/dist/src/ui/mouse.js +4 -2
  37. package/dist/src/ui/text.js +167 -0
  38. package/package.json +4 -2
  39. package/plugins/fetch-url.mjs +82 -0
  40. package/plugins/project-rules.mjs +43 -0
  41. package/plugins/run-tests.mjs +89 -0
  42. 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,13 @@
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
+ # (LITELLM_PROXY_API_BASE and LITELLM_PROXY_API_KEY, the names LiteLLM's SDK uses, work too.)
33
+ # NOU_MODEL=litellm:<a model name from your proxy>
34
+ # Or skip this file: /connect <provider> inside Nou saves keys to ~/.nou/credentials.json.
22
35
 
23
36
  # Claude effort level: low | medium | high | xhigh | max (default high)
24
37
  # 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? Install [Ollama](https://ollama.com), run `ollama pull qwen3:8b`, and you're set. That's the default model.
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,26 +76,31 @@ Every change is reviewed like a code review:
73
76
 
74
77
  | Key | Does |
75
78
  | --- | --- |
76
- | 🖱️ 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) |
79
+ | 🖱️ Mouse | wheel scrolls the pane under the pointer · click a pane to focus it · click a file to open it · drag a border between panes to resize them (hold <kbd>Shift</kbd> to select text, or press <kbd>m</kbd> to turn the mouse off) |
77
80
  | <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd>, <kbd>PgUp</kbd>/<kbd>PgDn</kbd> | scroll the chat history |
78
81
  | <kbd>Tab</kbd> | switch between files, code and chat |
82
+ | <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
79
83
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file |
80
84
  | <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) |
81
85
  | <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
82
86
  | <kbd>u</kbd> | undo Nou's last edit |
83
- | <kbd>v</kbd> | diff the open file vs git `HEAD` (or Nou's last edit) · <kbd>n</kbd>/<kbd>N</kbd> jump between changes |
87
+ | <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 |
84
88
  | <kbd>c</kbd> | in the file tree: show only changed files |
85
- | <kbd>f</kbd> | follow mode: jump to files as Nou reads and edits them |
89
+ | <kbd>f</kbd> | follow mode (off by default): jump to files as Nou reads and edits them |
90
+ | <kbd>&lt;</kbd> / <kbd>&gt;</kbd> / <kbd>=</kbd> | make the focused pane narrower / wider / reset the layout |
91
+ | <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal |
86
92
  | <kbd>s</kbd> | settings: model, preferences, plugins |
87
- | <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command |
93
+ | <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command, from any pane; the rest of Nou keeps working while it waits (<kbd>o</kbd> shows the edit) |
88
94
  | <kbd>Esc</kbd> | stop Nou · close a diff · back to chat |
89
95
  | <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
90
96
 
91
- Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/undo` · `/rewind` · `/history` · `/problems` · `/clear` · `/help`
97
+ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/help`
92
98
 
93
99
  ## 🧠 Models
94
100
 
95
- 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).
101
+ 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.
102
+
103
+ 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".
96
104
 
97
105
  <details>
98
106
  <summary><b>All providers and how to set them up</b></summary>
@@ -103,15 +111,34 @@ Pick one in <kbd>s</kbd> settings, with `/model anthropic` or `/model ollama:qwe
103
111
  | 🖥️ LM Studio | 🟢 local | start its local server |
104
112
  | ⚙️ llama.cpp server | 🟢 local | start `llama-server` |
105
113
  | 🔧 Any OpenAI-compatible server | 🟢 local | `NOU_BASE_URL`, optional `NOU_API_KEY` |
114
+ | 🚅 LiteLLM proxy or any hosted gateway | ☁️ cloud | `/connect litellm`, or `LITELLM_BASE_URL` + `LITELLM_API_KEY` (LiteLLM's `LITELLM_PROXY_API_BASE` + `LITELLM_PROXY_API_KEY` work too) |
106
115
  | 🟣 Anthropic (Claude) | ☁️ cloud | `ANTHROPIC_API_KEY` |
107
- | ⚪ OpenAI | ☁️ cloud | `OPENAI_API_KEY` |
116
+ | ⚪ OpenAI | ☁️ cloud | `OPENAI_API_KEY`, optional `OPENAI_BASE_URL` |
108
117
  | 🔷 Google Gemini | ☁️ cloud | `GEMINI_API_KEY` |
109
118
  | 🔀 OpenRouter | ☁️ cloud | `OPENROUTER_API_KEY` |
110
119
  | ⚡ Groq | ☁️ cloud | `GROQ_API_KEY` |
111
120
  | 🐋 DeepSeek | ☁️ cloud | `DEEPSEEK_API_KEY` |
121
+ | 🎆 Fireworks AI | ☁️ cloud | `/connect fireworks`, or `FIREWORKS_API_KEY` |
122
+
123
+ Every cloud provider can also be connected from inside Nou with `/connect <provider>`.
124
+
125
+ **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`.
112
126
 
113
127
  </details>
114
128
 
129
+ ## 🛞 Engines
130
+
131
+ 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.
132
+
133
+ | Engine | Models | Asks before edits and commands |
134
+ |:--|:--|:--:|
135
+ | `nou` *(default)* | any | ✅ |
136
+ | `claude-code` (Claude Agent SDK) | Claude; your API key or Claude login | ✅ |
137
+ | `pi` | your Nou model, local or cloud | ✅ |
138
+ | `codex` (Codex SDK) | OpenAI; your API key or ChatGPT login | ❌ edits in its sandbox; review and undo after |
139
+
140
+ 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`.
141
+
115
142
  ## 🔄 Updating
116
143
 
117
144
  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:
@@ -125,7 +152,16 @@ Set `NOU_NO_UPDATE_CHECK=1` to turn the check off.
125
152
 
126
153
  ## 🧩 Plugins *(early)*
127
154
 
128
- 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.
155
+ **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.
156
+
157
+ | Starter | What it adds |
158
+ |:--|:--|
159
+ | `run-tests` | `run_tests` tool and `/test`. Works out npm / pnpm / yarn / bun, cargo, go, pytest or make (asks before each run). |
160
+ | `todos` | `find_todos` tool and `/todos`: TODO / FIXME / HACK comments with file:line. |
161
+ | `fetch-url` | `fetch_url` tool so the model can read docs and web pages (asks before each URL). |
162
+ | `project-rules` | Feeds `AGENTS.md`, `CLAUDE.md`, `.cursorrules` and Copilot instructions to the model; `/rules` lists them. |
163
+
164
+ **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.
129
165
 
130
166
  ```js
131
167
  export default {
@@ -153,6 +189,7 @@ npm run dev # run from source
153
189
  npm run build # compile to dist/ (and copy the syntax grammars)
154
190
  npm test # end-to-end tests: drives the real UI with simulated keys
155
191
  npm run screenshot # regenerate docs/screenshot.svg
192
+ npm run eval # how well the agent does real tasks with a real model (see evals/)
156
193
  ```
157
194
 
158
195
  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
+ }
@@ -1,17 +1,34 @@
1
+ import { randomUUID } from "crypto";
1
2
  import { toolDefs } from "../tools/index.js";
2
- import { applyChange, fileTree, proposeEdit, proposeWrite, readFile, runCommand, toRelative, } from "../commands/index.js";
3
- import { describe } from "../providers/index.js";
3
+ import { applyChange, capOutput, fileTree, isInProject, proposeEdit, proposeWrite, readForModel, runCommand, toRelative, } from "../commands/index.js";
4
+ import { describe, keyHint } from "../providers/index.js";
4
5
  import { newCallId } from "../providers/ids.js";
5
- const MAX_STEPS = 25;
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
- return `You are Nou, a coding assistant running in the user's terminal, working in ${process.cwd()}.
8
- You can read, edit and create files and run commands with the tools you have.
9
- - Read a file before editing it. Prefer edit_file for small changes and write_file for new files.
10
- - Every edit and command is shown to the user, who approves or rejects it.
11
- - Keep replies short and to the point.
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 = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
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
- content: result.text,
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,
@@ -152,7 +272,8 @@ export class Agent {
152
272
  this.hooks.onEntry({ kind: "info", text: "Cancelled." });
153
273
  }
154
274
  else {
155
- this.hooks.onEntry({ kind: "error", text: `${describe(this.choice)}: ${error.message}` });
275
+ const hint = keyHint(this.choice.provider, String(error.message));
276
+ this.hooks.onEntry({ kind: "error", text: `${describe(this.choice)}: ${error.message}${hint ? `\n${hint}` : ""}` });
156
277
  }
157
278
  this.dropUnansweredCalls();
158
279
  }
@@ -180,19 +301,43 @@ export class Agent {
180
301
  try {
181
302
  switch (name) {
182
303
  case "read_file": {
183
- this.hooks.onRead(toRelative(args.path));
304
+ // Files outside the project (e.g. ~/.ssh) need your OK, like commands do.
305
+ if (!isInProject(args.path)) {
306
+ if (!(await this.hooks.approve({ kind: "tool", name, args }))) {
307
+ this.hooks.onEntry({ kind: "tool", text: `Denied: read ${args.path}` });
308
+ return "User denied reading this file: it is outside the project folder.";
309
+ }
310
+ }
311
+ else {
312
+ this.hooks.onRead(toRelative(args.path));
313
+ }
184
314
  this.hooks.onEntry({ kind: "tool", text: `Read ${args.path}` });
185
- return await readFile(args.path);
315
+ return await readForModel(args.path, args.offset, args.limit);
186
316
  }
187
317
  case "list_files":
188
318
  case "get_file_tree": {
189
319
  this.hooks.onEntry({ kind: "tool", text: `Listed ${args.path ?? "."}` });
190
- return fileTree(args.path ?? ".", 4);
320
+ return fileTree(args.path ?? ".", 4, 500);
321
+ }
322
+ case "grep": {
323
+ this.hooks.onEntry({ kind: "tool", text: `Searched for ${args.pattern}${args.path ? ` in ${args.path}` : ""}${args.glob ? ` (${args.glob})` : ""}` });
324
+ return grep({
325
+ pattern: String(args.pattern),
326
+ ...(typeof args.path === "string" ? { path: args.path } : {}),
327
+ ...(typeof args.glob === "string" ? { glob: args.glob } : {}),
328
+ ignoreCase: args.ignore_case === true,
329
+ context: Number(args.context) || 0,
330
+ filesOnly: args.files_only === true,
331
+ });
332
+ }
333
+ case "glob": {
334
+ this.hooks.onEntry({ kind: "tool", text: `Found files ${args.pattern}` });
335
+ return globFiles(String(args.pattern), typeof args.path === "string" ? args.path : ".");
191
336
  }
192
337
  case "edit_file":
193
338
  case "write_file": {
194
339
  const change = name === "edit_file"
195
- ? await proposeEdit(args.path, args.old_string, args.new_string)
340
+ ? await proposeEdit(args.path, args.old_string, args.new_string, args.replace_all === true)
196
341
  : await proposeWrite(args.path, args.content ?? "");
197
342
  return await this.reviewChange(change);
198
343
  }
@@ -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
- const after = normalized.replace(target, () => newString.replace(/\r\n/g, "\n")).replace(/\n/g, eol);
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) {