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.
- package/.env.example +13 -0
- package/README.md +47 -10
- package/dist/index.js +22 -2
- package/dist/src/agent/harness.js +54 -0
- package/dist/src/agent/index.js +162 -17
- package/dist/src/commands/index.js +54 -5
- package/dist/src/config/credentials.js +48 -0
- package/dist/src/config/index.js +4 -1
- package/dist/src/config/origins.js +14 -0
- package/dist/src/context/index.js +1 -1
- package/dist/src/engines/claude.js +147 -0
- package/dist/src/engines/codex.js +131 -0
- package/dist/src/engines/external.js +152 -0
- package/dist/src/engines/index.js +127 -0
- package/dist/src/engines/pi.js +161 -0
- package/dist/src/engines/types.js +1 -0
- package/dist/src/env.js +5 -1
- package/dist/src/git/index.js +14 -4
- package/dist/src/history/index.js +53 -1
- package/dist/src/plugins/index.js +109 -21
- package/dist/src/providers/anthropic.js +16 -1
- package/dist/src/providers/index.js +16 -0
- package/dist/src/providers/ollama.js +58 -1
- package/dist/src/providers/openai.js +26 -6
- package/dist/src/search/index.js +183 -0
- package/dist/src/setup/index.js +240 -0
- package/dist/src/syntax/index.js +11 -2
- package/dist/src/tools/index.js +72 -2
- package/dist/src/ui/App.js +452 -93
- package/dist/src/ui/Chat.js +19 -10
- package/dist/src/ui/CodeView.js +1 -1
- package/dist/src/ui/Editor.js +1 -1
- package/dist/src/ui/FileTree.js +12 -7
- package/dist/src/ui/Settings.js +91 -15
- package/dist/src/ui/clipboard.js +29 -0
- package/dist/src/ui/mouse.js +4 -2
- package/dist/src/ui/text.js +167 -0
- package/package.json +4 -2
- package/plugins/fetch-url.mjs +82 -0
- package/plugins/project-rules.mjs +43 -0
- package/plugins/run-tests.mjs +89 -0
- package/plugins/todos.mjs +81 -0
|
@@ -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
|
+
}
|
package/dist/src/git/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
32
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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 = () =>
|
|
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,4 +1,5 @@
|
|
|
1
1
|
import { loadSettings, saveSettings } from "../config/index.js";
|
|
2
|
+
import { originOf } from "../config/origins.js";
|
|
2
3
|
import { anthropicProvider } from "./anthropic.js";
|
|
3
4
|
import { ollamaProvider } from "./ollama.js";
|
|
4
5
|
import { OPENAI_COMPATIBLE, openAICompatibleProvider } from "./openai.js";
|
|
@@ -24,6 +25,21 @@ export function resolveModel(spec) {
|
|
|
24
25
|
return { provider: providers[0], model: trimmed };
|
|
25
26
|
}
|
|
26
27
|
export const describe = (choice) => `${choice.provider.id}:${choice.model}`;
|
|
28
|
+
/** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
|
|
29
|
+
export const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
|
|
30
|
+
/**
|
|
31
|
+
* For an error that says the key was rejected: which key, where it was set, and how to fix it.
|
|
32
|
+
* The usual cause is a project's .env holding a key for the app rather than for Nou.
|
|
33
|
+
*/
|
|
34
|
+
export function keyHint(provider, error) {
|
|
35
|
+
const key = provider.connect?.find((f) => f.secret)?.env;
|
|
36
|
+
if (!key || !rejectedKey(error))
|
|
37
|
+
return null;
|
|
38
|
+
const origin = originOf(key);
|
|
39
|
+
const others = providers.filter((p) => p !== provider && !p.local && !p.unavailable()).map((p) => p.id);
|
|
40
|
+
return `The ${key}${origin ? ` from ${origin}` : ""} was rejected. Enter another with /connect ${provider.id}`
|
|
41
|
+
+ (others.length ? `, or switch with /model ${others.join(" · /model ")}.` : ", or connect another provider (e.g. /connect litellm).");
|
|
42
|
+
}
|
|
27
43
|
/** NOU_MODEL wins, then the model saved in settings, then local Ollama. */
|
|
28
44
|
export function initialModel() {
|
|
29
45
|
if (process.env.NOU_MODEL)
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -2,11 +2,16 @@ import OpenAI from "openai";
|
|
|
2
2
|
import { newCallId } from "./ids.js";
|
|
3
3
|
/** Everything that speaks the OpenAI chat completions API. */
|
|
4
4
|
export const OPENAI_COMPATIBLE = [
|
|
5
|
-
{ id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
|
|
5
|
+
{ id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", urlEnv: "OPENAI_BASE_URL", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
|
|
6
6
|
{ id: "gemini", label: "Google Gemini", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", keyEnv: "GEMINI_API_KEY", defaultModel: "gemini-2.5-pro", local: false },
|
|
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",
|
|
13
|
+
// The names LiteLLM's own SDK uses for a proxy (its litellm_proxy provider).
|
|
14
|
+
aliases: { url: ["LITELLM_PROXY_API_BASE"], key: ["LITELLM_PROXY_API_KEY"] } },
|
|
10
15
|
{ id: "lmstudio", label: "LM Studio", baseURL: "http://localhost:1234/v1", urlEnv: "LMSTUDIO_BASE_URL", defaultModel: "local-model", local: true },
|
|
11
16
|
{ id: "llamacpp", label: "llama.cpp server", baseURL: "http://localhost:8080/v1", urlEnv: "LLAMACPP_BASE_URL", defaultModel: "local-model", local: true },
|
|
12
17
|
{ id: "custom", label: "Custom OpenAI-compatible", baseURL: "", urlEnv: "NOU_BASE_URL", keyEnv: "NOU_API_KEY", defaultModel: "default", local: true },
|
|
@@ -37,19 +42,34 @@ function toOpenAI(system, messages) {
|
|
|
37
42
|
return out;
|
|
38
43
|
}
|
|
39
44
|
export function openAICompatibleProvider(preset) {
|
|
40
|
-
|
|
41
|
-
const
|
|
45
|
+
// Read when used, not when loaded: keys found in other tools' config (see setup/) are added at startup.
|
|
46
|
+
const first = (names) => names.map((n) => n && process.env[n]).find(Boolean);
|
|
47
|
+
const baseURL = () => first([preset.urlEnv, ...(preset.aliases?.url ?? [])]) || preset.baseURL;
|
|
48
|
+
const apiKey = () => first([preset.keyEnv, ...(preset.aliases?.key ?? [])]) || (preset.local ? "not-needed" : "");
|
|
49
|
+
// Rebuilt when the URL or key changes (they can be entered while Nou runs).
|
|
42
50
|
let client = null;
|
|
43
|
-
const getClient = () =>
|
|
51
|
+
const getClient = () => {
|
|
52
|
+
const url = baseURL();
|
|
53
|
+
const key = apiKey();
|
|
54
|
+
if (client?.url !== url || client.key !== key)
|
|
55
|
+
client = { url, key, api: new OpenAI({ baseURL: url, apiKey: key }) };
|
|
56
|
+
return client.api;
|
|
57
|
+
};
|
|
58
|
+
const connect = [
|
|
59
|
+
...(preset.urlEnv ? [{ env: preset.urlEnv, label: "Base URL", optional: !!preset.baseURL, placeholder: preset.baseURL || "https://your-proxy.example.com/v1" }] : []),
|
|
60
|
+
...(preset.keyEnv ? [{ env: preset.keyEnv, label: "API key", secret: true, optional: preset.local, ...(preset.keyHint ? { placeholder: preset.keyHint } : {}) }] : []),
|
|
61
|
+
];
|
|
44
62
|
return {
|
|
45
63
|
id: preset.id,
|
|
46
64
|
label: preset.label,
|
|
47
65
|
local: preset.local,
|
|
48
66
|
defaultModel: preset.defaultModel,
|
|
67
|
+
...(connect.length ? { connect } : {}),
|
|
68
|
+
endpoint: () => ({ baseURL: baseURL(), apiKey: apiKey() || "not-needed", api: "openai-completions" }),
|
|
49
69
|
unavailable() {
|
|
50
|
-
if (!baseURL)
|
|
70
|
+
if (!baseURL())
|
|
51
71
|
return `set ${preset.urlEnv}`;
|
|
52
|
-
if (!apiKey)
|
|
72
|
+
if (!apiKey())
|
|
53
73
|
return `set ${preset.keyEnv}`;
|
|
54
74
|
return null;
|
|
55
75
|
},
|