nou-cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Little games for the Games panel, drawn with text. Each one is plain state and three functions, so the
3
+ * panel can run any of them and the tests can play them without a terminal.
4
+ */
5
+ /** A small seeded random number generator, so games can be replayed in tests. */
6
+ function rng(seed) {
7
+ let s = seed >>> 0 || 1;
8
+ return () => {
9
+ s ^= s << 13;
10
+ s ^= s >>> 17;
11
+ s ^= s << 5;
12
+ return (s >>> 0) / 4294967296;
13
+ };
14
+ }
15
+ const blank = (w, h) => Array.from({ length: h }, () => Array(w).fill(" "));
16
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
17
+ const PADDLE = 3;
18
+ const PONG_WIN = 7;
19
+ function serve(s, toward) {
20
+ const vy = (s.random() - 0.5) * 1.2;
21
+ return { ...s, bx: s.w / 2, by: s.h / 2, vx: toward * 0.9, vy: Math.abs(vy) < 0.2 ? 0.4 : vy };
22
+ }
23
+ export const pong = {
24
+ id: "pong",
25
+ title: "Pong",
26
+ blurb: "The classic: you on the left, the computer on the right. First to 7.",
27
+ controls: "↑↓ or w/s move",
28
+ tickMs: 50,
29
+ colors: { "█": "#61afef", "●": "#e5c07b", "┊": "#5c6370" },
30
+ create(w, h, seed = Date.now()) {
31
+ const s = { w, h, you: Math.floor((h - PADDLE) / 2), them: Math.floor((h - PADDLE) / 2), bx: 0, by: 0, vx: 0, vy: 0, scores: [0, 0], random: rng(seed) };
32
+ return serve(s, -1);
33
+ },
34
+ tick(s, inputs) {
35
+ if (s.scores[0] >= PONG_WIN || s.scores[1] >= PONG_WIN)
36
+ return s;
37
+ let you = s.you;
38
+ for (const i of inputs)
39
+ you += i === "up" ? -1 : i === "down" ? 1 : 0;
40
+ you = clamp(you, 0, s.h - PADDLE);
41
+ // The computer follows the ball, a bit slower than it moves, so it can be beaten.
42
+ const target = s.by - PADDLE / 2;
43
+ const them = clamp(s.them + clamp(target - s.them, -0.55, 0.55), 0, s.h - PADDLE);
44
+ let { bx, by, vx, vy } = s;
45
+ bx += vx;
46
+ by += vy;
47
+ if (by < 0)
48
+ [by, vy] = [-by, -vy];
49
+ if (by > s.h - 1)
50
+ [by, vy] = [2 * (s.h - 1) - by, -vy];
51
+ // Paddles at x=1 and x=w-2; where the ball hits the paddle sets its angle.
52
+ if (vx < 0 && bx <= 1.5 && bx >= 0.5 && by >= you - 0.5 && by <= you + PADDLE - 0.5) {
53
+ [bx, vx, vy] = [1.5, Math.min(1.4, -vx * 1.05), (by - (you + PADDLE / 2 - 0.5)) * 0.5];
54
+ }
55
+ if (vx > 0 && bx >= s.w - 2.5 && bx <= s.w - 1.5 && by >= them - 0.5 && by <= them + PADDLE - 0.5) {
56
+ [bx, vx, vy] = [s.w - 2.5, -Math.min(1.4, vx * 1.05), (by - (them + PADDLE / 2 - 0.5)) * 0.5];
57
+ }
58
+ const next = { ...s, you, them, bx, by, vx, vy };
59
+ if (bx < 0)
60
+ return serve({ ...next, scores: [s.scores[0], s.scores[1] + 1] }, -1);
61
+ if (bx > s.w - 1)
62
+ return serve({ ...next, scores: [s.scores[0] + 1, s.scores[1]] }, 1);
63
+ return next;
64
+ },
65
+ view(s) {
66
+ const grid = blank(s.w, s.h);
67
+ for (let y = 0; y < s.h; y += 2)
68
+ grid[y][Math.floor(s.w / 2)] = "┊";
69
+ for (let k = 0; k < PADDLE; k++) {
70
+ grid[s.you + k][1] = "█";
71
+ grid[Math.round(s.them) + k][s.w - 2] = "█";
72
+ }
73
+ const bx = Math.round(s.bx);
74
+ const by = Math.round(s.by);
75
+ if (grid[by]?.[bx] !== undefined)
76
+ grid[by][bx] = "●";
77
+ const [a, b] = s.scores;
78
+ return {
79
+ lines: grid.map((r) => r.join("")),
80
+ status: `you ${a} · ${b} computer ↑↓ move`,
81
+ ...(a >= PONG_WIN ? { over: "You win! ✦" } : b >= PONG_WIN ? { over: "The computer wins." } : {}),
82
+ };
83
+ },
84
+ };
85
+ const DIRS = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } };
86
+ const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" };
87
+ function placeFood(s) {
88
+ for (let tries = 0; tries < 500; tries++) {
89
+ const p = { x: Math.floor(s.random() * s.w), y: Math.floor(s.random() * s.h) };
90
+ if (!s.body.some((b) => b.x === p.x && b.y === p.y))
91
+ return p;
92
+ }
93
+ return { x: 0, y: 0 };
94
+ }
95
+ export const snake = {
96
+ id: "snake",
97
+ title: "Snake",
98
+ blurb: "Eat, grow, don't bite yourself or the walls.",
99
+ controls: "arrows or wasd steer",
100
+ tickMs: 110,
101
+ colors: { "█": "#98c379", "▓": "#7ec16e", "◆": "#e06c75" },
102
+ create(w, h, seed = Date.now()) {
103
+ const y = Math.floor(h / 2);
104
+ const s = { w, h, body: [{ x: 4, y }, { x: 3, y }, { x: 2, y }], dir: "right", food: { x: 0, y: 0 }, score: 0, dead: false, random: rng(seed) };
105
+ return { ...s, food: placeFood(s) };
106
+ },
107
+ tick(s, inputs) {
108
+ if (s.dead)
109
+ return s;
110
+ let dir = s.dir;
111
+ // One turn per tick (the first that isn't straight back), so quick double taps don't fold it onto itself.
112
+ for (const i of inputs) {
113
+ if (i !== OPPOSITE[dir] && i !== dir) {
114
+ dir = i;
115
+ break;
116
+ }
117
+ }
118
+ const head = { x: s.body[0].x + DIRS[dir].x, y: s.body[0].y + DIRS[dir].y };
119
+ const eats = head.x === s.food.x && head.y === s.food.y;
120
+ const rest = eats ? s.body : s.body.slice(0, -1);
121
+ const dead = head.x < 0 || head.y < 0 || head.x >= s.w || head.y >= s.h || rest.some((b) => b.x === head.x && b.y === head.y);
122
+ if (dead)
123
+ return { ...s, dir, dead: true };
124
+ const next = { ...s, dir, body: [head, ...rest], score: s.score + (eats ? 1 : 0) };
125
+ return eats ? { ...next, food: placeFood(next) } : next;
126
+ },
127
+ view(s) {
128
+ const grid = blank(s.w, s.h);
129
+ grid[s.food.y][s.food.x] = "◆";
130
+ s.body.forEach((b, i) => {
131
+ if (grid[b.y]?.[b.x] !== undefined)
132
+ grid[b.y][b.x] = i === 0 ? "█" : "▓";
133
+ });
134
+ return { lines: grid.map((r) => r.join("")), status: `length ${s.body.length} · score ${s.score}`, ...(s.dead ? { over: `Game over: ${s.score} eaten.` } : {}) };
135
+ },
136
+ };
137
+ const PAD = 7;
138
+ const BRICK_ROWS = 4;
139
+ function launch(s) {
140
+ return { ...s, bx: s.paddle + PAD / 2, by: s.h - 3, vx: (s.random() < 0.5 ? -1 : 1) * 0.6, vy: -0.6 };
141
+ }
142
+ export const breakout = {
143
+ id: "breakout",
144
+ title: "Breakout",
145
+ blurb: "Knock out every brick with the ball. Three lives.",
146
+ controls: "←→ or a/d move",
147
+ tickMs: 45,
148
+ colors: { "▀": "#e06c75", "▄": "#d19a66", "▬": "#61afef", "●": "#e5c07b" },
149
+ create(w, h, seed = Date.now()) {
150
+ const s = {
151
+ w, h, paddle: Math.floor((w - PAD) / 2), bx: 0, by: 0, vx: 0, vy: 0,
152
+ bricks: Array.from({ length: BRICK_ROWS }, () => Array(Math.floor(w / 3)).fill(true)), lives: 3, score: 0, random: rng(seed),
153
+ };
154
+ return launch(s);
155
+ },
156
+ tick(s, inputs) {
157
+ if (s.lives <= 0 || s.bricks.every((r) => r.every((b) => !b)))
158
+ return s;
159
+ let paddle = s.paddle;
160
+ for (const i of inputs)
161
+ paddle += i === "left" ? -2 : i === "right" ? 2 : 0;
162
+ paddle = clamp(paddle, 0, s.w - PAD);
163
+ let { bx, by, vx, vy } = s;
164
+ bx += vx;
165
+ by += vy;
166
+ if (bx < 0 || bx > s.w - 1)
167
+ [bx, vx] = [clamp(bx, 0, s.w - 1), -vx];
168
+ if (by < 0)
169
+ [by, vy] = [0, -vy];
170
+ // Bricks are three columns wide, on rows 1.. so there's a gap at the top.
171
+ const bricks = s.bricks.map((r) => [...r]);
172
+ let score = s.score;
173
+ const row = Math.floor(by) - 1;
174
+ const col = Math.floor(bx / 3);
175
+ if (row >= 0 && row < BRICK_ROWS && bricks[row]?.[col]) {
176
+ bricks[row][col] = false;
177
+ score += (BRICK_ROWS - row) * 10;
178
+ vy = -vy;
179
+ }
180
+ // The paddle sits on the last row; where the ball lands on it sets the angle.
181
+ if (vy > 0 && by >= s.h - 2 && by < s.h - 1 && bx >= paddle - 0.5 && bx <= paddle + PAD - 0.5) {
182
+ vy = -Math.abs(vy);
183
+ vx = clamp((bx - (paddle + PAD / 2 - 0.5)) * 0.25, -1, 1) || 0.3;
184
+ by = s.h - 2;
185
+ }
186
+ const next = { ...s, paddle, bx, by, vx, vy, bricks, score };
187
+ if (by > s.h - 1) {
188
+ const lives = s.lives - 1;
189
+ return lives > 0 ? launch({ ...next, lives }) : { ...next, lives };
190
+ }
191
+ return next;
192
+ },
193
+ view(s) {
194
+ const grid = blank(s.w, s.h);
195
+ s.bricks.forEach((r, y) => r.forEach((alive, x) => {
196
+ if (alive)
197
+ for (let k = 0; k < 3; k++)
198
+ if (x * 3 + k < s.w - 0)
199
+ grid[y + 1][x * 3 + k] = y % 2 ? "▄" : "▀";
200
+ }));
201
+ for (let k = 0; k < PAD; k++)
202
+ grid[s.h - 1][s.paddle + k] = "▬";
203
+ const bx = Math.round(s.bx);
204
+ const by = Math.round(s.by);
205
+ if (s.lives > 0 && grid[by]?.[bx] !== undefined)
206
+ grid[by][bx] = "●";
207
+ const cleared = s.bricks.every((r) => r.every((b) => !b));
208
+ return {
209
+ lines: grid.map((r) => r.join("")),
210
+ status: `score ${s.score} · lives ${"♥".repeat(Math.max(0, s.lives))}`,
211
+ ...(cleared ? { over: `Cleared! ${s.score} points ✦` } : s.lives <= 0 ? { over: `Game over: ${s.score} points.` } : {}),
212
+ };
213
+ },
214
+ };
215
+ /** Every game Nou has, in the order settings lists them. */
216
+ export const GAMES = [pong, snake, breakout];
217
+ export const gameById = (id) => GAMES.find((g) => g.id === id);
@@ -0,0 +1,106 @@
1
+ import { readFileSync } from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ function json(file) {
5
+ try {
6
+ return JSON.parse(readFileSync(file, "utf-8"));
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ /** Keeps the fields Nou uses, from a server entry in any of these formats. */
13
+ function normalize(raw) {
14
+ if (!raw || typeof raw !== "object")
15
+ return null;
16
+ const out = {};
17
+ if (typeof raw.command === "string")
18
+ out.command = raw.command;
19
+ if (Array.isArray(raw.args))
20
+ out.args = raw.args.map(String);
21
+ if (raw.env && typeof raw.env === "object")
22
+ out.env = Object.fromEntries(Object.entries(raw.env).map(([k, v]) => [k, String(v)]));
23
+ if (typeof raw.cwd === "string")
24
+ out.cwd = raw.cwd;
25
+ const url = raw.url ?? raw.serverUrl ?? raw.httpUrl;
26
+ if (typeof url === "string")
27
+ out.url = url;
28
+ if (raw.headers && typeof raw.headers === "object")
29
+ out.headers = Object.fromEntries(Object.entries(raw.headers).map(([k, v]) => [k, String(v)]));
30
+ if (raw.disabled === true)
31
+ out.disabled = true;
32
+ return out.command || out.url ? out : null;
33
+ }
34
+ /** The [mcp_servers.<name>] tables of a Codex config.toml (just the keys MCP servers use). */
35
+ export function codexServers(toml) {
36
+ const out = {};
37
+ let current = null;
38
+ let sub = null;
39
+ const value = (v) => {
40
+ v = v.trim();
41
+ if (v.startsWith("\"") || v.startsWith("'"))
42
+ return v.slice(1, v.lastIndexOf(v[0])).replace(/\\"/g, "\"");
43
+ if (v.startsWith("["))
44
+ return [...v.matchAll(/"((?:\\.|[^"\\])*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
45
+ if (v.startsWith("{"))
46
+ return Object.fromEntries([...v.matchAll(/([\w-]+)\s*=\s*"((?:\\.|[^"\\])*)"/g)].map((m) => [m[1], m[2]]));
47
+ if (v === "true" || v === "false")
48
+ return v === "true";
49
+ return v;
50
+ };
51
+ for (const raw of toml.split(/\r?\n/)) {
52
+ const line = raw.replace(/\s+#.*$/, "").trim();
53
+ const table = line.match(/^\[mcp_servers\.("?)([^"\].]+)\1(?:\.(\w+))?\]$/);
54
+ if (table) {
55
+ out[table[2]] ??= {};
56
+ current = out[table[2]];
57
+ sub = table[3] ?? null;
58
+ if (sub)
59
+ current[sub] ??= {};
60
+ continue;
61
+ }
62
+ if (line.startsWith("[")) {
63
+ current = null;
64
+ continue;
65
+ }
66
+ const kv = line.match(/^([\w-]+)\s*=\s*(.+)$/);
67
+ if (current && kv)
68
+ (sub ? current[sub] : current)[kv[1]] = value(kv[2]);
69
+ }
70
+ return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, normalize({ ...v, disabled: v.enabled === false })]).filter(([, v]) => v));
71
+ }
72
+ /** Every MCP server set up in the other tools on this machine (and in this project), first source wins per name. */
73
+ export function findOtherMcpServers(home = os.homedir(), cwd = process.cwd()) {
74
+ const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
75
+ const claudeDesktop = process.platform === "darwin"
76
+ ? path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
77
+ : process.platform === "win32" ? path.join(appData, "Claude", "claude_desktop_config.json") : path.join(home, ".config", "Claude", "claude_desktop_config.json");
78
+ const claudeCode = json(path.join(home, ".claude.json"));
79
+ const sources = [
80
+ { from: "Claude Code", servers: claudeCode?.mcpServers },
81
+ { from: "Claude Code (this project)", servers: claudeCode?.projects?.[cwd]?.mcpServers },
82
+ { from: "Claude Desktop", servers: json(claudeDesktop)?.mcpServers },
83
+ { from: "Cursor", servers: json(path.join(home, ".cursor", "mcp.json"))?.mcpServers },
84
+ { from: "Cursor (this project)", servers: json(path.join(cwd, ".cursor", "mcp.json"))?.mcpServers },
85
+ { from: "Windsurf", servers: json(path.join(home, ".codeium", "windsurf", "mcp_config.json"))?.mcpServers },
86
+ { from: "VS Code (this project)", servers: json(path.join(cwd, ".vscode", "mcp.json"))?.servers },
87
+ { from: "Gemini CLI", servers: json(path.join(home, ".gemini", "settings.json"))?.mcpServers },
88
+ ];
89
+ try {
90
+ sources.push({ from: "Codex", servers: codexServers(readFileSync(path.join(home, ".codex", "config.toml"), "utf-8")) });
91
+ }
92
+ catch {
93
+ // no Codex config
94
+ }
95
+ const found = [];
96
+ for (const { from, servers } of sources) {
97
+ if (!servers || typeof servers !== "object")
98
+ continue;
99
+ for (const [name, raw] of Object.entries(servers)) {
100
+ const config = normalize(raw);
101
+ if (config && !found.some((f) => f.name === name))
102
+ found.push({ name, config, from });
103
+ }
104
+ }
105
+ return found;
106
+ }
@@ -0,0 +1,184 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import path from "path";
3
+ import { NOU_HOME } from "../config/index.js";
4
+ export const USER_MCP_FILE = path.join(NOU_HOME, "mcp.json");
5
+ /** Project MCP files (only read in a folder you trust: they start programs). */
6
+ const PROJECT_MCP_FILES = [".mcp.json", path.join(".nou", "mcp.json")];
7
+ /** Ready-made servers: `/mcp add browser` and so on. */
8
+ export const MCP_PRESETS = {
9
+ browser: { config: { command: "npx", args: ["-y", "@playwright/mcp@latest"] }, about: "control a browser: open pages, click, type, read them, take screenshots (Playwright)" },
10
+ fetch: { config: { command: "uvx", args: ["mcp-server-fetch"] }, about: "fetch web pages as text (needs uv)" },
11
+ };
12
+ function readServers(file) {
13
+ try {
14
+ const json = JSON.parse(readFileSync(file, "utf-8"));
15
+ const servers = json?.mcpServers ?? json?.servers ?? {};
16
+ return typeof servers === "object" && servers ? servers : {};
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ /** Every configured server: yours (~/.nou/mcp.json) and, in a trusted folder, the project's. */
23
+ export function loadMcpServers(trusted) {
24
+ const out = [];
25
+ for (const [name, config] of Object.entries(readServers(USER_MCP_FILE)))
26
+ out.push({ name, config, scope: "user", file: USER_MCP_FILE });
27
+ if (trusted) {
28
+ for (const rel of PROJECT_MCP_FILES) {
29
+ const file = path.resolve(rel);
30
+ for (const [name, config] of Object.entries(readServers(file))) {
31
+ if (!out.some((e) => e.name === name))
32
+ out.push({ name, config, scope: "project", file });
33
+ }
34
+ }
35
+ }
36
+ return out.filter((e) => e.config && (e.config.command || e.config.url));
37
+ }
38
+ /** Adds (or replaces) servers in ~/.nou/mcp.json. */
39
+ export function saveUserServers(servers) {
40
+ let json = {};
41
+ try {
42
+ json = JSON.parse(readFileSync(USER_MCP_FILE, "utf-8"));
43
+ }
44
+ catch {
45
+ // a new file
46
+ }
47
+ json.mcpServers = { ...(json.mcpServers ?? {}), ...servers };
48
+ if (!existsSync(NOU_HOME))
49
+ mkdirSync(NOU_HOME, { recursive: true });
50
+ writeFileSync(USER_MCP_FILE, JSON.stringify(json, null, 2), { mode: 0o600 });
51
+ try {
52
+ chmodSync(USER_MCP_FILE, 0o600); // it can hold tokens
53
+ }
54
+ catch {
55
+ // Windows ignores it
56
+ }
57
+ }
58
+ export function removeUserServer(name) {
59
+ try {
60
+ const json = JSON.parse(readFileSync(USER_MCP_FILE, "utf-8"));
61
+ if (!json.mcpServers?.[name])
62
+ return false;
63
+ delete json.mcpServers[name];
64
+ writeFileSync(USER_MCP_FILE, JSON.stringify(json, null, 2), { mode: 0o600 });
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /** Tool names the model sees: mcp__server__tool, in the characters every provider accepts. */
72
+ const toolName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
73
+ /** Keeps the end of a server's stderr, to say why it failed (it never reaches the screen). */
74
+ function tailOf(stream) {
75
+ let text = "";
76
+ stream?.on("data", (chunk) => {
77
+ text = (text + String(chunk)).slice(-2000);
78
+ });
79
+ return () => text.trim().split("\n").slice(-3).join(" ").slice(0, 300);
80
+ }
81
+ /**
82
+ * Connects to the MCP servers and turns their tools into tools for Nou's engine. A server's output goes to
83
+ * a buffer, never the terminal, so it can't scribble over Nou's screen; tools that change things ask
84
+ * first, like Nou's own.
85
+ */
86
+ export class McpManager {
87
+ onChange;
88
+ servers = new Map();
89
+ constructor(onChange) {
90
+ this.onChange = onChange;
91
+ }
92
+ status() {
93
+ return [...this.servers.values()].map((s) => s.status);
94
+ }
95
+ /** Connects to every enabled server in `entries` (already connected ones are kept). */
96
+ async connect(entries) {
97
+ const wanted = new Set(entries.map((e) => e.name));
98
+ for (const [name, s] of this.servers) {
99
+ if (!wanted.has(name)) {
100
+ void s.client?.close().catch(() => { });
101
+ this.servers.delete(name);
102
+ }
103
+ }
104
+ await Promise.all(entries.filter((e) => !this.servers.has(e.name)).map((e) => this.start(e)));
105
+ }
106
+ async start(entry) {
107
+ const status = { name: entry.name, scope: entry.scope, state: entry.config.disabled ? "off" : "connecting", tools: 0 };
108
+ const slot = { status, tools: [] };
109
+ this.servers.set(entry.name, slot);
110
+ this.onChange();
111
+ if (entry.config.disabled)
112
+ return;
113
+ let stderr = () => "";
114
+ try {
115
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
116
+ const client = new Client({ name: "nou", version: "1" });
117
+ let transport;
118
+ if (entry.config.url) {
119
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
120
+ transport = new StreamableHTTPClientTransport(new URL(entry.config.url), entry.config.headers ? { requestInit: { headers: entry.config.headers } } : undefined);
121
+ }
122
+ else {
123
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
124
+ transport = new StdioClientTransport({
125
+ command: entry.config.command,
126
+ args: entry.config.args ?? [],
127
+ // The SDK adds a safe base (PATH, HOME...) to these: your API keys aren't passed to every server.
128
+ ...(entry.config.env ? { env: entry.config.env } : {}),
129
+ cwd: entry.config.cwd ?? process.cwd(),
130
+ stderr: "pipe",
131
+ });
132
+ stderr = tailOf(transport.stderr);
133
+ }
134
+ await withTimeout(client.connect(transport), 30_000, "didn't answer within 30 seconds");
135
+ const { tools } = await withTimeout(client.listTools(), 15_000, "didn't list its tools");
136
+ Object.assign(slot, { client, transport, tools });
137
+ slot.status = { ...status, state: "ready", tools: tools.length };
138
+ }
139
+ catch (error) {
140
+ const why = stderr();
141
+ slot.status = { ...status, state: "failed", error: `${error?.message ?? error}${why ? ` (${why})` : ""}` };
142
+ }
143
+ this.onChange();
144
+ }
145
+ /** The connected servers' tools, ready for the engine. */
146
+ tools() {
147
+ const out = [];
148
+ for (const [server, s] of this.servers) {
149
+ if (s.status.state !== "ready")
150
+ continue;
151
+ for (const tool of s.tools) {
152
+ const schema = tool.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
153
+ out.push({
154
+ definition: {
155
+ name: toolName(server, tool.name),
156
+ description: `[${server}] ${tool.description ?? tool.name}`.slice(0, 1000),
157
+ parameters: { ...schema, type: "object", properties: schema.properties ?? {}, required: Array.isArray(schema.required) ? schema.required : [] },
158
+ },
159
+ // Only tools that say they don't change anything run without asking.
160
+ readOnly: tool.annotations?.readOnlyHint === true,
161
+ source: `mcp:${server}`,
162
+ run: async (args) => {
163
+ const result = await s.client.callTool({ name: tool.name, arguments: args }, undefined, { timeout: 180_000 });
164
+ const parts = (result.content ?? []).map((c) => (c.type === "text" ? c.text : c.type === "resource" ? c.resource?.text ?? "" : `[${c.type}]`));
165
+ const text = parts.join("\n").trim() || "(no output)";
166
+ return result.isError ? `Error: ${text}` : text;
167
+ },
168
+ });
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+ close() {
174
+ for (const s of this.servers.values())
175
+ void s.client?.close().catch(() => { });
176
+ this.servers.clear();
177
+ }
178
+ }
179
+ function withTimeout(promise, ms, why) {
180
+ return new Promise((resolve, reject) => {
181
+ const timer = setTimeout(() => reject(new Error(why)), ms);
182
+ promise.then((v) => (clearTimeout(timer), resolve(v)), (e) => (clearTimeout(timer), reject(e)));
183
+ });
184
+ }
@@ -37,6 +37,21 @@ function toAnthropic(messages) {
37
37
  }
38
38
  return out;
39
39
  }
40
+ /**
41
+ * Marks the end of the conversation for prompt caching, so the next request (every tool step of a turn)
42
+ * reads all of it from the cache instead of paying for it again: cached input costs a tenth or less.
43
+ */
44
+ function withCacheBreakpoint(params) {
45
+ const last = params[params.length - 1];
46
+ if (!last)
47
+ return params;
48
+ const content = typeof last.content === "string" ? [{ type: "text", text: last.content }] : [...last.content];
49
+ const end = content[content.length - 1];
50
+ if (!end || end.type === "thinking" || end.type === "redacted_thinking")
51
+ return params;
52
+ content[content.length - 1] = { ...end, cache_control: { type: "ephemeral" } };
53
+ return [...params.slice(0, -1), { ...last, content }];
54
+ }
40
55
  function hasCredentials() {
41
56
  return !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_PROFILE)
42
57
  || existsSync(path.join(os.homedir(), ".config", "anthropic"));
@@ -46,8 +61,9 @@ export function anthropicProvider() {
46
61
  let client = null;
47
62
  const getClient = () => {
48
63
  const auth = `${process.env.ANTHROPIC_API_KEY}|${process.env.ANTHROPIC_AUTH_TOKEN}|${process.env.ANTHROPIC_BASE_URL}`;
64
+ // No retries here: the agent retries failed requests itself (with backoff), and both would multiply.
49
65
  if (client?.auth !== auth)
50
- client = { auth, api: new Anthropic() };
66
+ client = { auth, api: new Anthropic({ maxRetries: 0 }) };
51
67
  return client.api;
52
68
  };
53
69
  return {
@@ -72,7 +88,7 @@ export function anthropicProvider() {
72
88
  model,
73
89
  max_tokens: 64000,
74
90
  system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
75
- messages: toAnthropic(messages),
91
+ messages: withCacheBreakpoint(toAnthropic(messages)),
76
92
  tools: tools.map((t) => ({
77
93
  name: t.name,
78
94
  description: t.description,
@@ -80,6 +96,8 @@ export function anthropicProvider() {
80
96
  eager_input_streaming: true,
81
97
  })),
82
98
  stream: true,
99
+ // Current models think between tool calls; summarized, that shows what they're doing instead of a silence.
100
+ ...(EFFORT_MODELS.test(model) ? { thinking: { type: "adaptive", display: "summarized" } } : {}),
83
101
  ...(effort ? { output_config: { effort: effort } } : {}),
84
102
  ...(fallbacks ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" } : {}),
85
103
  };
@@ -87,7 +105,20 @@ export function anthropicProvider() {
87
105
  let text = "";
88
106
  try {
89
107
  const stream = getClient().beta.messages.stream(body, signal ? { signal } : {});
108
+ // Thinking streams as a quote above the reply (the chat shows quotes dimmed); only the reply is kept.
109
+ let thinking = false;
110
+ stream.on("thinking", (delta) => {
111
+ const piece = `${thinking ? "" : "> "}${delta.replace(/\n/g, "\n> ")}`;
112
+ thinking = true;
113
+ text += piece;
114
+ onText(piece);
115
+ });
90
116
  stream.on("text", (delta) => {
117
+ if (thinking) {
118
+ thinking = false;
119
+ text += "\n\n";
120
+ onText("\n\n");
121
+ }
91
122
  text += delta;
92
123
  onText(delta);
93
124
  });
@@ -27,6 +27,19 @@ export function resolveModel(spec) {
27
27
  export const describe = (choice) => `${choice.provider.id}:${choice.model}`;
28
28
  /** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
29
29
  export const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
30
+ /**
31
+ * Whether an error is the provider's setup rather than the request: a rejected key, a URL that doesn't
32
+ * answer, or a model it doesn't have. Each has a place in settings to fix it.
33
+ */
34
+ export function setupProblem(provider, error) {
35
+ if (rejectedKey(error) && provider.connect?.some((f) => f.secret))
36
+ return "key";
37
+ if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|getaddrinfo|fetch failed|Connection error|Invalid URL|connect ETIMEDOUT/i.test(error) && provider.connect?.some((f) => !f.secret))
38
+ return "url";
39
+ if (/model\b[^.]*\b(not found|does not exist|not exist|invalid|unknown|not available|not supported)|(invalid|unknown|no such) model|model_not_found/i.test(error))
40
+ return "model";
41
+ return null;
42
+ }
30
43
  /**
31
44
  * For an error that says the key was rejected: which key, where it was set, and how to fix it.
32
45
  * The usual cause is a project's .env holding a key for the app rather than for Nou.
@@ -52,7 +52,7 @@ export function openAICompatibleProvider(preset) {
52
52
  const url = baseURL();
53
53
  const key = apiKey();
54
54
  if (client?.url !== url || client.key !== key)
55
- client = { url, key, api: new OpenAI({ baseURL: url, apiKey: key }) };
55
+ client = { url, key, api: new OpenAI({ baseURL: url, apiKey: key, maxRetries: 0 }) };
56
56
  return client.api;
57
57
  };
58
58
  const connect = [
@@ -73,9 +73,13 @@ export function openAICompatibleProvider(preset) {
73
73
  return `set ${preset.keyEnv}`;
74
74
  return null;
75
75
  },
76
- async chat({ model, system, messages, tools, onText, signal }) {
76
+ async chat({ model, system, messages, tools, onText, signal, effort }) {
77
+ // OpenAI's reasoning models take an effort too (named in the model id, through a gateway too). Nou's
78
+ // default, high, is their default, medium; xhigh and max are their high.
79
+ const reasoning = effort && /(^|\/)(gpt-5|o\d)/.test(model) && !/-chat|o1-(preview|mini)/.test(model) ? { low: "low", medium: "low", high: "medium" }[effort] ?? "high" : null;
77
80
  const stream = await getClient().chat.completions.create({
78
81
  model,
82
+ ...(reasoning ? { reasoning_effort: reasoning } : {}),
79
83
  messages: toOpenAI(system, messages),
80
84
  ...(tools.length ? { tools: tools.map((t) => ({ type: "function", function: t })) } : {}),
81
85
  stream: true,