nou-cli 0.2.1 → 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.
@@ -0,0 +1,161 @@
1
+ import { toRelative } from "../commands/index.js";
2
+ import { ExternalEngine, replaceIn } from "./external.js";
3
+ import { loadPackage } from "./index.js";
4
+ import { readFileSync } from "fs";
5
+ import path from "path";
6
+ /** Nou provider id -> pi's id for the same service, where pi has it built in. */
7
+ const PI_PROVIDERS = {
8
+ anthropic: "anthropic",
9
+ openai: "openai",
10
+ gemini: "google",
11
+ openrouter: "openrouter",
12
+ groq: "groq",
13
+ deepseek: "deepseek",
14
+ };
15
+ const THINKING = { low: "low", medium: "medium", high: "high", xhigh: "high", max: "high" };
16
+ /** The file's content after a pi edit or write, or undefined if it can't be worked out in advance. */
17
+ export function piProposedContent(tool, input) {
18
+ if (tool === "write")
19
+ return String(input.content ?? "");
20
+ let text;
21
+ try {
22
+ text = readFileSync(path.resolve(process.cwd(), input.path), "utf-8");
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ // pi matches every oldText against the original file, so apply them to it one at a time.
28
+ for (const edit of Array.isArray(input.edits) ? input.edits : [])
29
+ text = text === null ? null : replaceIn(text, edit.oldText, edit.newText);
30
+ return text ?? undefined;
31
+ }
32
+ /**
33
+ * pi through its SDK, with the model picked in Nou (pi's built-in provider when it has one, otherwise
34
+ * Nou's endpoint for it, e.g. Ollama). An inline extension sends pi's edits and commands to Nou's approval.
35
+ */
36
+ export class PiEngine extends ExternalEngine {
37
+ id = "pi";
38
+ piSession = null;
39
+ constructor(choice, hooks) {
40
+ super(choice, hooks, "pi");
41
+ }
42
+ resetConversation() {
43
+ this.piSession?.dispose();
44
+ this.piSession = null;
45
+ }
46
+ dispose() {
47
+ this.resetConversation();
48
+ }
49
+ /** The pi model for Nou's current choice. */
50
+ async model(pi, runtime) {
51
+ const { provider, model } = this.choice;
52
+ const builtIn = PI_PROVIDERS[provider.id] && runtime.getModel(PI_PROVIDERS[provider.id], model);
53
+ if (builtIn)
54
+ return builtIn;
55
+ const endpoint = provider.endpoint?.();
56
+ if (!endpoint)
57
+ throw new Error(`pi can't use ${provider.label}`);
58
+ const id = `nou-${provider.id}`;
59
+ runtime.registerProvider(id, {
60
+ baseUrl: endpoint.baseURL,
61
+ apiKey: endpoint.apiKey || "none",
62
+ api: endpoint.api,
63
+ // pi needs the full model description; these are safe defaults for a model it doesn't know.
64
+ models: [{
65
+ id: model, name: model, input: ["text"], reasoning: false, contextWindow: 32_768, maxTokens: 8192,
66
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
67
+ }],
68
+ });
69
+ const found = runtime.getModel(id, model);
70
+ if (!found)
71
+ throw new Error(`pi couldn't set up ${provider.id}:${model}`);
72
+ return found;
73
+ }
74
+ async start() {
75
+ const pi = await loadPackage("@earendil-works/pi-coding-agent");
76
+ const runtime = await pi.ModelRuntime.create();
77
+ const cwd = process.cwd();
78
+ const loader = new pi.DefaultResourceLoader({
79
+ cwd,
80
+ agentDir: pi.getAgentDir(),
81
+ appendSystemPromptOverride: (base) => [...base, ...this.extras],
82
+ extensionFactories: [(api) => this.connect(api)],
83
+ });
84
+ await loader.reload();
85
+ const { session } = await pi.createAgentSession({
86
+ cwd,
87
+ model: await this.model(pi, runtime),
88
+ modelRuntime: runtime,
89
+ resourceLoader: loader,
90
+ sessionManager: pi.SessionManager.inMemory(),
91
+ ...(this.effort && THINKING[this.effort] ? { thinkingLevel: THINKING[this.effort] } : {}),
92
+ });
93
+ return session;
94
+ }
95
+ /** Nou's approvals, undo and problem checks, as a pi extension. */
96
+ connect(api) {
97
+ api.on("tool_call", async (event) => {
98
+ const input = event.input ?? {};
99
+ if ((event.toolName === "edit" || event.toolName === "write") && input.path) {
100
+ const ok = await this.tracker.approve(input.path, piProposedContent(event.toolName, input), event.toolName);
101
+ return ok ? undefined : { block: true, reason: "The user rejected this change. Ask them what they would like instead." };
102
+ }
103
+ if (event.toolName === "bash" || event.toolName === "powershell") {
104
+ const ok = await this.hooks.approve({ kind: "command", command: String(input.command ?? "") });
105
+ if (!ok)
106
+ this.hooks.onEntry({ kind: "tool", text: `Denied: ${input.command}` });
107
+ return ok ? undefined : { block: true, reason: "The user denied permission to run this command." };
108
+ }
109
+ if (["read", "grep", "find", "ls"].includes(event.toolName))
110
+ return undefined;
111
+ // Tools from pi extensions or packages: ask, like Nou's own plugin tools.
112
+ const ok = await this.hooks.approve({ kind: "tool", name: event.toolName, args: input });
113
+ return ok ? undefined : { block: true, reason: "The user denied this." };
114
+ });
115
+ api.on("tool_result", async (event) => {
116
+ const input = event.input ?? {};
117
+ if ((event.toolName === "edit" || event.toolName === "write") && input.path) {
118
+ const problems = await this.tracker.applied(input.path);
119
+ if (problems)
120
+ return { content: [...event.content, { type: "text", text: `\n\nThis edit introduced problems; fix them:\n${problems}` }] };
121
+ return undefined;
122
+ }
123
+ if (event.toolName === "read" && input.path)
124
+ this.hooks.onRead(toRelative(input.path));
125
+ const target = input.command ?? input.path ?? input.pattern ?? "";
126
+ this.hooks.onEntry({ kind: "tool", text: event.toolName === "bash" || event.toolName === "powershell" ? `Ran: ${target}` : `${event.toolName} ${target}`.trim() });
127
+ return undefined;
128
+ });
129
+ }
130
+ async run(prompt, signal) {
131
+ this.piSession ??= await this.start();
132
+ const session = this.piSession;
133
+ let streamed = "";
134
+ const unsubscribe = session.subscribe((event) => {
135
+ if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
136
+ streamed += event.assistantMessageEvent.delta;
137
+ this.hooks.onStream(streamed);
138
+ }
139
+ else if (event.type === "message_end" && event.message?.role === "assistant") {
140
+ streamed = "";
141
+ this.hooks.onStream("");
142
+ const text = (event.message.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("").trim();
143
+ if (text)
144
+ this.hooks.onEntry({ kind: "assistant", text });
145
+ if (event.message.stopReason === "error" && !signal.aborted)
146
+ this.hooks.onEntry({ kind: "error", text: `pi: ${event.message.errorMessage ?? "the request failed"}` });
147
+ }
148
+ });
149
+ const onAbort = () => void session.abort();
150
+ signal.addEventListener("abort", onAbort);
151
+ try {
152
+ await session.prompt(prompt);
153
+ }
154
+ finally {
155
+ signal.removeEventListener("abort", onAbort);
156
+ unsubscribe();
157
+ }
158
+ if (signal.aborted)
159
+ throw new Error("aborted");
160
+ }
161
+ }
@@ -0,0 +1 @@
1
+ export const ENGINE_IDS = ["nou", "claude-code", "codex", "pi"];
package/dist/src/env.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "fs";
2
+ import { noteOrigin } from "./config/origins.js";
2
3
  // This module must be imported before anything else (index.ts imports it first).
3
4
  // npm only warns about an unsupported Node version, so check here, before Ink loads and fails confusingly.
4
5
  const [major, minor] = process.versions.node.split(".").map(Number);
@@ -8,5 +9,8 @@ if (major < 22 || (major === 22 && minor < 12)) {
8
9
  }
9
10
  // Load the project's .env (API keys, base URLs). Providers read their settings when they load,
10
11
  // so loading .env later would be too late for them.
11
- if (existsSync(".env"))
12
+ if (existsSync(".env")) {
13
+ const before = new Set(Object.keys(process.env));
12
14
  process.loadEnvFile(".env");
15
+ noteOrigin(Object.keys(process.env).filter((name) => !before.has(name)), ".env");
16
+ }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "child_process";
1
+ import { execFile, execFileSync } from "child_process";
2
2
  import path from "path";
3
3
  function git(args) {
4
4
  return execFileSync("git", args, { cwd: process.cwd(), encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 20 * 1024 * 1024 });
@@ -25,11 +25,21 @@ function fromRoot(p) {
25
25
  function toRoot(p) {
26
26
  return path.relative(repoRoot(), path.resolve(process.cwd(), p)).replace(/\\/g, "/");
27
27
  }
28
+ const STATUS_ARGS = ["status", "--porcelain=v1", "-z", "-uall"];
28
29
  export function status() {
29
- const result = new Map();
30
+ return isRepo() ? parseStatus(tryGit(STATUS_ARGS)) : new Map();
31
+ }
32
+ /** status() without blocking: for background refreshes, where a large repo can take a while. */
33
+ export function statusAsync() {
30
34
  if (!isRepo())
31
- return result;
32
- const out = tryGit(["status", "--porcelain=v1", "-z", "-uall"]);
35
+ return Promise.resolve(new Map());
36
+ return new Promise((resolve) => {
37
+ execFile("git", STATUS_ARGS, { cwd: process.cwd(), encoding: "utf-8", maxBuffer: 20 * 1024 * 1024 }, (error, out) => resolve(parseStatus(error ? null : out)));
38
+ });
39
+ }
40
+ export const sameStatus = (a, b) => a.size === b.size && [...a].every(([file, code]) => b.get(file) === code);
41
+ function parseStatus(out) {
42
+ const result = new Map();
33
43
  if (!out)
34
44
  return result;
35
45
  const parts = out.split("\0");
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
3
3
  import path from "path";
4
4
  import { applyPatch, structuredPatch } from "diff";
5
5
  import { NOU_HOME } from "../config/index.js";
6
+ /** "<session>:<turn>" -> session id (null for older entries that stored a bare turn number). */
7
+ const sessionOf = (turn) => (typeof turn === "string" && turn.includes(":") ? turn.slice(0, turn.lastIndexOf(":")) : null);
6
8
  const MAX_CHECKPOINTS = 300;
7
9
  const MAX_FILE_BYTES = 1_000_000;
8
10
  function readOrNull(file) {
@@ -19,6 +21,8 @@ export class Timeline {
19
21
  items = [];
20
22
  nextId = 1;
21
23
  file;
24
+ saveTimer = null;
25
+ exitHook = null;
22
26
  constructor(onUpdate = () => { }) {
23
27
  this.onUpdate = onUpdate;
24
28
  const key = createHash("sha1").update(process.cwd()).digest("hex").slice(0, 16);
@@ -58,6 +62,37 @@ export class Timeline {
58
62
  return [];
59
63
  return this.items.filter((c) => c.source === "nou" && !c.undone && c.turn === last.turn).reverse();
60
64
  }
65
+ /** Sessions that changed files, newest first. Older history entries without a session id are skipped. */
66
+ sessions() {
67
+ const byId = new Map();
68
+ for (const c of this.items) {
69
+ const id = sessionOf(c.turn);
70
+ if (!id)
71
+ continue;
72
+ const s = byId.get(id) ?? { id, start: c.time, end: c.time, files: [] };
73
+ s.end = c.time;
74
+ if (!s.files.includes(c.path))
75
+ s.files.push(c.path);
76
+ byId.set(id, s);
77
+ }
78
+ return [...byId.values()].reverse();
79
+ }
80
+ /**
81
+ * The net change to each file over one session (yours and Nou's, undos included): from the file as it
82
+ * was before the session's first change to it, to after its last. Files that ended up unchanged are left out.
83
+ */
84
+ sessionChanges(session) {
85
+ const byPath = new Map();
86
+ for (const c of this.items) {
87
+ if (sessionOf(c.turn) !== session)
88
+ continue;
89
+ const seen = byPath.get(c.path);
90
+ byPath.set(c.path, { before: seen ? seen.before : c.before, after: c.after });
91
+ }
92
+ return [...byPath]
93
+ .filter(([, { before, after }]) => before !== after)
94
+ .map(([file, { before, after }]) => ({ path: file, before: before ?? "", after: after ?? "", isNew: before === null }));
95
+ }
61
96
  /**
62
97
  * Reverts one checkpoint. If the file changed since, only Nou's part is reversed (patch merge);
63
98
  * if that conflicts, nothing is written.
@@ -99,7 +134,25 @@ export class Timeline {
99
134
  this.save();
100
135
  return { ok: true, path: checkpoint.path, merged };
101
136
  }
137
+ /**
138
+ * History can hold hundreds of whole files, so it's written shortly after changes stop (an agent turn
139
+ * makes many in a row) instead of rewriting the file on every edit. flush() writes it now.
140
+ */
102
141
  save() {
142
+ if (!this.saveTimer) {
143
+ this.saveTimer = setTimeout(() => this.flush(), 500);
144
+ this.saveTimer.unref?.();
145
+ if (!this.exitHook) {
146
+ this.exitHook = () => this.flush();
147
+ process.on("exit", this.exitHook);
148
+ }
149
+ }
150
+ this.onUpdate();
151
+ }
152
+ flush() {
153
+ if (this.saveTimer)
154
+ clearTimeout(this.saveTimer);
155
+ this.saveTimer = null;
103
156
  try {
104
157
  if (!existsSync(path.dirname(this.file)))
105
158
  mkdirSync(path.dirname(this.file), { recursive: true });
@@ -108,6 +161,5 @@ export class Timeline {
108
161
  catch {
109
162
  // history is best effort
110
163
  }
111
- this.onUpdate();
112
164
  }
113
165
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "crypto";
2
- import { existsSync, readdirSync, readFileSync, statSync } from "fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
3
3
  import path from "path";
4
- import { pathToFileURL } from "url";
4
+ import { fileURLToPath, pathToFileURL } from "url";
5
5
  import { NOU_HOME } from "../config/index.js";
6
6
  import { eslintProvider, ruffProvider, typescriptProvider } from "../diagnostics/providers.js";
7
7
  import { syntaxProvider } from "../syntax/index.js";
@@ -90,6 +90,27 @@ export const PLUGIN_DIRS = [
90
90
  { scope: "user", dir: path.join(NOU_HOME, "plugins") },
91
91
  ];
92
92
  const EXTENSIONS = [".js", ".mjs", ".ts", ".mts"];
93
+ /** The entry for a plugin file or folder, or undefined if it isn't one. */
94
+ function entryFor(full, scope) {
95
+ const name = path.basename(full);
96
+ let file;
97
+ if (statSync(full).isDirectory()) {
98
+ file = EXTENSIONS.map((ext) => path.join(full, `index${ext}`)).find(existsSync);
99
+ }
100
+ else if (EXTENSIONS.includes(path.extname(name))) {
101
+ file = full;
102
+ }
103
+ if (!file)
104
+ return undefined;
105
+ let hash;
106
+ try {
107
+ hash = hashPlugin(full);
108
+ }
109
+ catch {
110
+ // unreadable: stays untrusted
111
+ }
112
+ return { id: path.basename(name, path.extname(name)), file, root: full, scope, ...(hash ? { hash } : {}) };
113
+ }
93
114
  /** Finds plugin files without running them. */
94
115
  export function discoverPlugins() {
95
116
  const found = [];
@@ -97,29 +118,96 @@ export function discoverPlugins() {
97
118
  if (!existsSync(dir))
98
119
  continue;
99
120
  for (const name of readdirSync(dir)) {
100
- const full = path.join(dir, name);
101
- let file;
102
- if (statSync(full).isDirectory()) {
103
- file = EXTENSIONS.map((ext) => path.join(full, `index${ext}`)).find(existsSync);
104
- }
105
- else if (EXTENSIONS.includes(path.extname(name))) {
106
- file = full;
107
- }
108
- const id = path.basename(name, path.extname(name));
109
- if (file && !found.some((p) => p.id === id)) {
110
- let hash;
111
- try {
112
- hash = hashPlugin(full);
113
- }
114
- catch {
115
- // unreadable: stays untrusted
116
- }
117
- found.push({ id, file, root: full, scope, ...(hash ? { hash } : {}) });
118
- }
121
+ const entry = entryFor(path.join(dir, name), scope);
122
+ if (entry && !found.some((p) => p.id === entry.id))
123
+ found.push(entry);
119
124
  }
120
125
  }
121
126
  return found;
122
127
  }
128
+ /** The plugins/ folder of the Nou package (next to package.json, from source or the installed build). */
129
+ function starterDir() {
130
+ let dir = path.dirname(fileURLToPath(import.meta.url));
131
+ for (let i = 0; i < 5; i++) {
132
+ if (existsSync(path.join(dir, "package.json")) && existsSync(path.join(dir, "plugins")))
133
+ return path.join(dir, "plugins");
134
+ dir = path.dirname(dir);
135
+ }
136
+ return undefined;
137
+ }
138
+ /**
139
+ * Optional plugins that ship with Nou to get you started. They're Nou's own code, so they can be loaded to
140
+ * show their name and description, but they only run once you add one (see addStarter).
141
+ */
142
+ export function starterPlugins() {
143
+ const dir = starterDir();
144
+ if (!dir)
145
+ return [];
146
+ return readdirSync(dir)
147
+ .filter((name) => EXTENSIONS.includes(path.extname(name)))
148
+ .sort()
149
+ .map((name) => ({ id: path.basename(name, path.extname(name)), file: path.join(dir, name), scope: "starter" }));
150
+ }
151
+ /**
152
+ * Adds a starter plugin: copies it into ~/.nou/plugins/ (keeping any copy already there), where it works like
153
+ * a plugin you wrote. Returns the new entry; switch it on with trustPatch.
154
+ */
155
+ export function addStarter(starter) {
156
+ const dir = PLUGIN_DIRS.find((d) => d.scope === "user").dir;
157
+ mkdirSync(dir, { recursive: true });
158
+ const target = path.join(dir, path.basename(starter.file));
159
+ if (!existsSync(target))
160
+ copyFileSync(starter.file, target);
161
+ return entryFor(target, "user");
162
+ }
163
+ const TEMPLATE = (name) => `// Nou plugin. Edit it, then switch it on in /settings → Plugins (edits need switching on again).
164
+ // More: the starter plugins in ~/.nou/plugins/ once added, and the Plugins section of Nou's README.
165
+
166
+ export default {
167
+ name: ${JSON.stringify(name)},
168
+ version: "0.1.0",
169
+ description: "What this plugin does.",
170
+
171
+ // Extra instructions for the model while the plugin is on.
172
+ // systemPrompt: "Use ${name.replace(/\W/g, "_")}_hello when ...",
173
+
174
+ tools: [
175
+ {
176
+ readOnly: true, // read-only tools run without asking; leave this out for anything that changes things
177
+ definition: {
178
+ name: ${JSON.stringify(`${name.replace(/\W/g, "_")}_hello`)},
179
+ description: "Say hello (replace with what your tool does)",
180
+ parameters: { type: "object", properties: { who: { type: "string", description: "Who to greet" } } },
181
+ },
182
+ run(args, ctx) {
183
+ return \`Hello \${args.who ?? "world"} from \${ctx.cwd}\`;
184
+ },
185
+ },
186
+ ],
187
+
188
+ commands: [
189
+ {
190
+ name: ${JSON.stringify(name)},
191
+ description: "Try it: /${name} [args]",
192
+ run(args, ctx) {
193
+ return \`/${name} ran in \${ctx.cwd} with "\${args}"\`;
194
+ },
195
+ },
196
+ ],
197
+ };
198
+ `;
199
+ /** Creates .nou/plugins/<name>.mjs in this project from a commented template. Returns its path. */
200
+ export function scaffoldPlugin(name) {
201
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(name))
202
+ throw new Error("use letters, numbers and dashes, e.g. /plugins new deploy");
203
+ const dir = PLUGIN_DIRS.find((d) => d.scope === "project").dir;
204
+ const file = path.join(dir, `${name}.mjs`);
205
+ if (existsSync(file))
206
+ throw new Error(`${path.relative(process.cwd(), file)} already exists`);
207
+ mkdirSync(dir, { recursive: true });
208
+ writeFileSync(file, TEMPLATE(name));
209
+ return file;
210
+ }
123
211
  export async function loadPlugin(entry) {
124
212
  try {
125
213
  const mod = await import(pathToFileURL(entry.file).href);
@@ -42,14 +42,29 @@ function hasCredentials() {
42
42
  || existsSync(path.join(os.homedir(), ".config", "anthropic"));
43
43
  }
44
44
  export function anthropicProvider() {
45
+ // Rebuilt when the key or URL changes (they can be entered while Nou runs).
45
46
  let client = null;
46
- const getClient = () => (client ??= new Anthropic());
47
+ const getClient = () => {
48
+ const auth = `${process.env.ANTHROPIC_API_KEY}|${process.env.ANTHROPIC_AUTH_TOKEN}|${process.env.ANTHROPIC_BASE_URL}`;
49
+ if (client?.auth !== auth)
50
+ client = { auth, api: new Anthropic() };
51
+ return client.api;
52
+ };
47
53
  return {
48
54
  id: "anthropic",
49
55
  label: "Anthropic (Claude)",
50
56
  local: false,
51
57
  defaultModel: "claude-opus-5-5",
58
+ connect: [
59
+ { env: "ANTHROPIC_API_KEY", label: "API key", secret: true, placeholder: "console.anthropic.com/settings/keys" },
60
+ { env: "ANTHROPIC_BASE_URL", label: "Base URL", optional: true, placeholder: "https://api.anthropic.com" },
61
+ ],
52
62
  unavailable: () => (hasCredentials() ? null : "set ANTHROPIC_API_KEY (or run `ant auth login`)"),
63
+ endpoint: () => ({
64
+ baseURL: process.env.ANTHROPIC_BASE_URL || "https://api.anthropic.com",
65
+ apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "",
66
+ api: "anthropic-messages",
67
+ }),
53
68
  async chat({ model, system, messages, tools, onText, signal, effort: requested }) {
54
69
  const fallbacks = FALLBACK_MODELS.test(model);
55
70
  const effort = EFFORT_MODELS.test(model) ? process.env.NOU_EFFORT ?? requested ?? "high" : undefined;
@@ -1,8 +1,44 @@
1
1
  import { Ollama } from "ollama";
2
2
  import { newCallId } from "./ids.js";
3
+ /** Ollama names without a tag mean ":latest", so "llama3" and "llama3:latest" are the same model. */
4
+ export const sameModel = (a, b) => {
5
+ const full = (m) => (m.includes(":") ? m : `${m}:latest`);
6
+ return full(a) === full(b);
7
+ };
8
+ /** Context window to ask for when a model doesn't set its own: enough for the system prompt, files and history. */
9
+ const DEFAULT_NUM_CTX = 32_768;
10
+ /**
11
+ * Ollama's default context window is small, and it silently drops whatever doesn't fit (often the system
12
+ * prompt). Ask for a bigger one, capped at what the model supports, unless its Modelfile sets num_ctx.
13
+ * Returns null to leave Ollama's setting alone.
14
+ */
15
+ export function pickNumCtx(parameters, modelInfo) {
16
+ if (/^num_ctx\s/m.test(parameters ?? ""))
17
+ return null;
18
+ const max = Object.entries(modelInfo ?? {}).find(([key]) => key.endsWith(".context_length"))?.[1];
19
+ return typeof max === "number" && max > 0 ? Math.min(DEFAULT_NUM_CTX, max) : DEFAULT_NUM_CTX;
20
+ }
3
21
  export function ollamaProvider() {
4
22
  const host = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
5
23
  const client = new Ollama({ host });
24
+ const contextSizes = new Map();
25
+ /** NOU_NUM_CTX wins; otherwise worked out once per model from what Ollama reports about it. */
26
+ const numCtx = (model, via) => {
27
+ if (process.env.NOU_NUM_CTX)
28
+ return Promise.resolve(Number(process.env.NOU_NUM_CTX) || null);
29
+ let size = contextSizes.get(model);
30
+ if (!size) {
31
+ // model_info is typed as a Map but arrives as a plain object.
32
+ size = via.show({ model })
33
+ .then((info) => pickNumCtx(info.parameters, info.model_info))
34
+ .catch(() => {
35
+ contextSizes.delete(model); // cancelled or failed: ask again next time
36
+ return null;
37
+ });
38
+ contextSizes.set(model, size);
39
+ }
40
+ return size;
41
+ };
6
42
  const toOllama = (system, messages) => [
7
43
  { role: "system", content: system },
8
44
  ...messages.map((m) => {
@@ -23,10 +59,18 @@ export function ollamaProvider() {
23
59
  local: true,
24
60
  defaultModel: "qwen3:8b",
25
61
  unavailable: () => null,
62
+ endpoint: () => ({ baseURL: `${host.replace(/\/$/, "")}/v1`, apiKey: "ollama", api: "openai-completions" }),
26
63
  async chat({ model, system, messages, tools, onText, signal }) {
27
- const stream = await client.chat({
64
+ // The library can only abort once the reply starts streaming, but Ollama first reads the whole prompt
65
+ // (minutes for a long one on a slow machine). Passing our signal to the requests themselves lets Esc stop that too.
66
+ const requester = signal
67
+ ? new Ollama({ host, fetch: (input, init) => fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal }) })
68
+ : client;
69
+ const num_ctx = await numCtx(model, requester);
70
+ const stream = await requester.chat({
28
71
  model,
29
72
  messages: toOllama(system, messages),
73
+ ...(num_ctx ? { options: { num_ctx } } : {}),
30
74
  tools: tools.map((t) => ({ type: "function", function: t })),
31
75
  stream: true,
32
76
  });
@@ -54,5 +98,18 @@ export function ollamaProvider() {
54
98
  const { models } = await client.list();
55
99
  return models.map((m) => m.name);
56
100
  },
101
+ async pull(model, onProgress, signal) {
102
+ const stream = await client.pull({ model, stream: true });
103
+ const onAbort = () => stream.abort();
104
+ signal?.addEventListener("abort", onAbort);
105
+ try {
106
+ for await (const part of stream) {
107
+ onProgress(part.status, part.total ? Math.floor((part.completed / part.total) * 100) : null);
108
+ }
109
+ }
110
+ finally {
111
+ signal?.removeEventListener("abort", onAbort);
112
+ }
113
+ },
57
114
  };
58
115
  }
@@ -7,6 +7,9 @@ export const OPENAI_COMPATIBLE = [
7
7
  { id: "openrouter", label: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY", defaultModel: "anthropic/claude-opus-5-5", local: false },
8
8
  { id: "groq", label: "Groq", baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY", defaultModel: "llama-3.3-70b-versatile", local: false },
9
9
  { id: "deepseek", label: "DeepSeek", baseURL: "https://api.deepseek.com/v1", keyEnv: "DEEPSEEK_API_KEY", defaultModel: "deepseek-chat", local: false },
10
+ { id: "fireworks", label: "Fireworks AI", baseURL: "https://api.fireworks.ai/inference/v1", keyEnv: "FIREWORKS_API_KEY", defaultModel: "accounts/fireworks/models/kimi-k2-instruct", local: false, keyHint: "fireworks.ai/account/api-keys" },
11
+ // A LiteLLM proxy, or any hosted OpenAI-compatible gateway: cloud-sized context, unlike "custom".
12
+ { id: "litellm", label: "LiteLLM / gateway", baseURL: "", urlEnv: "LITELLM_BASE_URL", keyEnv: "LITELLM_API_KEY", defaultModel: "gpt-5", local: false, keyHint: "your proxy's virtual key or master key" },
10
13
  { id: "lmstudio", label: "LM Studio", baseURL: "http://localhost:1234/v1", urlEnv: "LMSTUDIO_BASE_URL", defaultModel: "local-model", local: true },
11
14
  { id: "llamacpp", label: "llama.cpp server", baseURL: "http://localhost:8080/v1", urlEnv: "LLAMACPP_BASE_URL", defaultModel: "local-model", local: true },
12
15
  { id: "custom", label: "Custom OpenAI-compatible", baseURL: "", urlEnv: "NOU_BASE_URL", keyEnv: "NOU_API_KEY", defaultModel: "default", local: true },
@@ -37,19 +40,33 @@ function toOpenAI(system, messages) {
37
40
  return out;
38
41
  }
39
42
  export function openAICompatibleProvider(preset) {
40
- const baseURL = (preset.urlEnv && process.env[preset.urlEnv]) || preset.baseURL;
41
- const apiKey = (preset.keyEnv && process.env[preset.keyEnv]) || (preset.local ? "not-needed" : "");
43
+ // Read when used, not when loaded: keys found in other tools' config (see setup/) are added at startup.
44
+ const baseURL = () => (preset.urlEnv && process.env[preset.urlEnv]) || preset.baseURL;
45
+ const apiKey = () => (preset.keyEnv && process.env[preset.keyEnv]) || (preset.local ? "not-needed" : "");
46
+ // Rebuilt when the URL or key changes (they can be entered while Nou runs).
42
47
  let client = null;
43
- const getClient = () => (client ??= new OpenAI({ baseURL, apiKey }));
48
+ const getClient = () => {
49
+ const url = baseURL();
50
+ const key = apiKey();
51
+ if (client?.url !== url || client.key !== key)
52
+ client = { url, key, api: new OpenAI({ baseURL: url, apiKey: key }) };
53
+ return client.api;
54
+ };
55
+ const connect = [
56
+ ...(preset.urlEnv ? [{ env: preset.urlEnv, label: "Base URL", optional: !!preset.baseURL, placeholder: preset.baseURL || "https://your-proxy.example.com/v1" }] : []),
57
+ ...(preset.keyEnv ? [{ env: preset.keyEnv, label: "API key", secret: true, optional: preset.local, ...(preset.keyHint ? { placeholder: preset.keyHint } : {}) }] : []),
58
+ ];
44
59
  return {
45
60
  id: preset.id,
46
61
  label: preset.label,
47
62
  local: preset.local,
48
63
  defaultModel: preset.defaultModel,
64
+ ...(connect.length ? { connect } : {}),
65
+ endpoint: () => ({ baseURL: baseURL(), apiKey: apiKey() || "not-needed", api: "openai-completions" }),
49
66
  unavailable() {
50
- if (!baseURL)
67
+ if (!baseURL())
51
68
  return `set ${preset.urlEnv}`;
52
- if (!apiKey)
69
+ if (!apiKey())
53
70
  return `set ${preset.keyEnv}`;
54
71
  return null;
55
72
  },