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,48 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { NOU_HOME } from "./index.js";
|
|
4
|
+
import { noteOrigin } from "./origins.js";
|
|
5
|
+
/**
|
|
6
|
+
* API keys and base URLs you enter inside Nou (settings → Model, or /connect), saved as env var name -> value.
|
|
7
|
+
* They fill in only what your environment and .env leave unset, so those always win.
|
|
8
|
+
*/
|
|
9
|
+
const CREDENTIALS_PATH = path.join(NOU_HOME, "credentials.json");
|
|
10
|
+
function readSaved() {
|
|
11
|
+
try {
|
|
12
|
+
const saved = JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
|
|
13
|
+
return Object.fromEntries(Object.entries(saved).filter((e) => typeof e[1] === "string" && !!e[1]));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Fills in saved keys and URLs for anything not already set. Call before choosing a model. */
|
|
20
|
+
export function loadCredentials() {
|
|
21
|
+
for (const [name, value] of Object.entries(readSaved())) {
|
|
22
|
+
if (process.env[name])
|
|
23
|
+
continue;
|
|
24
|
+
process.env[name] = value;
|
|
25
|
+
noteOrigin([name], "Nou (~/.nou/credentials.json)");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Saves keys and URLs (readable only by you) and applies them to this session. Empty values are skipped. */
|
|
29
|
+
export function saveCredentials(values) {
|
|
30
|
+
const next = readSaved();
|
|
31
|
+
for (const [name, value] of Object.entries(values)) {
|
|
32
|
+
const trimmed = value.trim();
|
|
33
|
+
if (!trimmed)
|
|
34
|
+
continue;
|
|
35
|
+
next[name] = trimmed;
|
|
36
|
+
process.env[name] = trimmed;
|
|
37
|
+
noteOrigin([name], "Nou (~/.nou/credentials.json)");
|
|
38
|
+
}
|
|
39
|
+
if (!existsSync(NOU_HOME))
|
|
40
|
+
mkdirSync(NOU_HOME, { recursive: true });
|
|
41
|
+
writeFileSync(CREDENTIALS_PATH, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
42
|
+
try {
|
|
43
|
+
chmodSync(CREDENTIALS_PATH, 0o600); // the mode above only applies when the file is created
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Windows ignores it
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/src/config/index.js
CHANGED
|
@@ -7,13 +7,16 @@ export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
|
7
7
|
export const AUTO_SAVE = ["delay", "focus", "off"];
|
|
8
8
|
export const DEFAULT_SETTINGS = {
|
|
9
9
|
model: "",
|
|
10
|
+
engine: "nou",
|
|
10
11
|
effort: "high",
|
|
11
|
-
followMode:
|
|
12
|
+
followMode: false,
|
|
12
13
|
autoApproveEdits: false,
|
|
13
14
|
autoApproveCommands: false,
|
|
14
15
|
gitContext: true,
|
|
15
16
|
autoSave: "delay",
|
|
16
17
|
mouse: true,
|
|
18
|
+
reuseToolConfig: true,
|
|
19
|
+
paneWidths: null,
|
|
17
20
|
plugins: {},
|
|
18
21
|
trustedPlugins: {},
|
|
19
22
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Where each API key or URL came from (env var name -> e.g. ".env"), so a rejected key can say where to fix it. */
|
|
2
|
+
const origins = new Map();
|
|
3
|
+
export function noteOrigin(names, origin) {
|
|
4
|
+
for (const name of names)
|
|
5
|
+
origins.set(name, origin);
|
|
6
|
+
}
|
|
7
|
+
/** Where a setting came from, or undefined if it isn't set. */
|
|
8
|
+
export function originOf(name) {
|
|
9
|
+
if (!process.env[name])
|
|
10
|
+
return undefined;
|
|
11
|
+
return origins.get(name) ?? "your environment";
|
|
12
|
+
}
|
|
13
|
+
/** Whether it was set outside Nou (your shell or .env), where it wins over a key saved in Nou on the next start. */
|
|
14
|
+
export const setOutsideNou = (name) => ["your environment", ".env"].includes(originOf(name) ?? "");
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { toRelative } from "../commands/index.js";
|
|
4
|
+
import { ExternalEngine, replaceIn } from "./external.js";
|
|
5
|
+
import { loadPackage } from "./index.js";
|
|
6
|
+
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
7
|
+
/** Claude Code tools that change files; Nou previews and approves them. */
|
|
8
|
+
const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
|
|
9
|
+
function current(filePath) {
|
|
10
|
+
try {
|
|
11
|
+
return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The file's content after a Claude Code edit, or undefined if it can't be worked out in advance. */
|
|
18
|
+
export function proposedContent(tool, input) {
|
|
19
|
+
if (tool === "Write")
|
|
20
|
+
return String(input.content ?? "");
|
|
21
|
+
const before = current(input.file_path);
|
|
22
|
+
if (before === null)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (tool === "Edit")
|
|
25
|
+
return replaceIn(before, input.old_string, input.new_string, input.replace_all === true) ?? undefined;
|
|
26
|
+
if (tool === "MultiEdit" && Array.isArray(input.edits)) {
|
|
27
|
+
let text = before;
|
|
28
|
+
for (const edit of input.edits)
|
|
29
|
+
text = text === null ? null : replaceIn(text, edit.old_string, edit.new_string, edit.replace_all === true);
|
|
30
|
+
return text ?? undefined;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
/** A short line for the chat about a tool Claude Code ran. */
|
|
35
|
+
function describeTool(tool, input) {
|
|
36
|
+
const target = input.file_path ?? input.path ?? input.pattern ?? input.url ?? input.query ?? input.description ?? "";
|
|
37
|
+
return `${tool}${target ? ` ${typeof target === "string" && input.file_path ? toRelative(target) : target}` : ""}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Claude Code through the Claude Agent SDK. Its PreToolUse hook sends every edit and command to Nou's
|
|
41
|
+
* approval (with a diff for edits); PostToolUse records what changed for undo and feeds problems back.
|
|
42
|
+
*/
|
|
43
|
+
export class ClaudeCodeEngine extends ExternalEngine {
|
|
44
|
+
id = "claude-code";
|
|
45
|
+
/** Claude Code's session, resumed for each message so the conversation carries on. */
|
|
46
|
+
claudeSession;
|
|
47
|
+
constructor(choice, hooks) {
|
|
48
|
+
super(choice, hooks, "Claude Code");
|
|
49
|
+
}
|
|
50
|
+
resetConversation() {
|
|
51
|
+
this.claudeSession = undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Claude Code can resume its session with another model, so the conversation carries over. */
|
|
54
|
+
setModel(choice) {
|
|
55
|
+
this.choice = choice;
|
|
56
|
+
}
|
|
57
|
+
async run(prompt, signal) {
|
|
58
|
+
const sdk = await loadPackage("@anthropic-ai/claude-agent-sdk");
|
|
59
|
+
const abortController = new AbortController();
|
|
60
|
+
signal.addEventListener("abort", () => abortController.abort());
|
|
61
|
+
const allow = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } };
|
|
62
|
+
const deny = (reason) => ({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } });
|
|
63
|
+
const preToolUse = async (input) => {
|
|
64
|
+
const tool = input.tool_name;
|
|
65
|
+
const args = (input.tool_input ?? {});
|
|
66
|
+
if (EDIT_TOOLS.has(tool) && args.file_path) {
|
|
67
|
+
const ok = await this.tracker.approve(args.file_path, proposedContent(tool, args), tool);
|
|
68
|
+
return ok ? allow : deny("The user rejected this change. Ask them what they would like instead.");
|
|
69
|
+
}
|
|
70
|
+
if (tool === "Bash" || tool === "PowerShell") {
|
|
71
|
+
const ok = await this.hooks.approve({ kind: "command", command: String(args.command ?? "") });
|
|
72
|
+
if (!ok)
|
|
73
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: ${args.command}` });
|
|
74
|
+
return ok ? allow : deny("The user denied permission to run this command.");
|
|
75
|
+
}
|
|
76
|
+
// Reads and searches follow Claude Code's own rules; anything else that needs asking reaches canUseTool.
|
|
77
|
+
return {};
|
|
78
|
+
};
|
|
79
|
+
const postToolUse = async (input) => {
|
|
80
|
+
const tool = input.tool_name;
|
|
81
|
+
const args = (input.tool_input ?? {});
|
|
82
|
+
if (EDIT_TOOLS.has(tool) && args.file_path) {
|
|
83
|
+
const problems = await this.tracker.applied(args.file_path);
|
|
84
|
+
return problems
|
|
85
|
+
? { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: `This edit introduced problems; fix them:\n${problems}` } }
|
|
86
|
+
: {};
|
|
87
|
+
}
|
|
88
|
+
if (tool === "Read" && args.file_path)
|
|
89
|
+
this.hooks.onRead(toRelative(args.file_path));
|
|
90
|
+
this.hooks.onEntry({ kind: "tool", text: tool === "Bash" || tool === "PowerShell" ? `Ran: ${args.command}` : describeTool(tool, args) });
|
|
91
|
+
return {};
|
|
92
|
+
};
|
|
93
|
+
const query = sdk.query({
|
|
94
|
+
prompt,
|
|
95
|
+
options: {
|
|
96
|
+
cwd: process.cwd(),
|
|
97
|
+
abortController,
|
|
98
|
+
...(this.claudeSession ? { resume: this.claudeSession } : {}),
|
|
99
|
+
...(this.choice.provider.id === "anthropic" ? { model: this.choice.model } : {}),
|
|
100
|
+
...(this.effort && EFFORTS.has(this.effort) ? { effort: this.effort } : {}),
|
|
101
|
+
includePartialMessages: true,
|
|
102
|
+
// Ask for everything not explicitly allowed, so Nou's approvals apply; project settings bring CLAUDE.md.
|
|
103
|
+
permissionMode: "default",
|
|
104
|
+
settingSources: ["project"],
|
|
105
|
+
systemPrompt: { type: "preset", preset: "claude_code", append: this.extras.join("\n\n") },
|
|
106
|
+
hooks: { PreToolUse: [{ hooks: [preToolUse] }], PostToolUse: [{ hooks: [postToolUse] }] },
|
|
107
|
+
canUseTool: async (tool, input) => {
|
|
108
|
+
const ok = await this.hooks.approve({ kind: "tool", name: tool, args: input });
|
|
109
|
+
if (!ok)
|
|
110
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: ${describeTool(tool, input)}` });
|
|
111
|
+
return ok ? { behavior: "allow", updatedInput: input } : { behavior: "deny", message: "The user denied this." };
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
let streamed = "";
|
|
116
|
+
for await (const message of query) {
|
|
117
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
118
|
+
this.claudeSession = message.session_id;
|
|
119
|
+
}
|
|
120
|
+
else if (message.type === "stream_event" && !message.parent_tool_use_id) {
|
|
121
|
+
const event = message.event;
|
|
122
|
+
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
123
|
+
streamed += event.delta.text;
|
|
124
|
+
this.hooks.onStream(streamed);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else if (message.type === "assistant" && !message.parent_tool_use_id) {
|
|
128
|
+
const text = message.message.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
|
|
129
|
+
streamed = "";
|
|
130
|
+
this.hooks.onStream("");
|
|
131
|
+
if (text)
|
|
132
|
+
this.hooks.onEntry({ kind: "assistant", text });
|
|
133
|
+
this.hooks.onBusy("Claude Code is working...");
|
|
134
|
+
}
|
|
135
|
+
else if (message.type === "result") {
|
|
136
|
+
this.claudeSession = message.session_id ?? this.claudeSession;
|
|
137
|
+
if (message.subtype !== "success" || message.is_error) {
|
|
138
|
+
const detail = message.errors?.join("; ") || message.result || message.subtype;
|
|
139
|
+
if (!signal.aborted)
|
|
140
|
+
this.hooks.onEntry({ kind: "error", text: `Claude Code: ${detail}` });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (signal.aborted)
|
|
145
|
+
throw new Error("aborted");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { statSync } from "fs";
|
|
3
|
+
import { listAllFiles } from "../ui/QuickOpen.js";
|
|
4
|
+
import { ExternalEngine } from "./external.js";
|
|
5
|
+
import { loadPackage } from "./index.js";
|
|
6
|
+
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
7
|
+
const MAX_FILES = 5000;
|
|
8
|
+
const MAX_BYTES = 512 * 1024;
|
|
9
|
+
/** Project files small enough to keep a copy of (git's list when there is one, so ignored files are skipped). */
|
|
10
|
+
function projectFiles() {
|
|
11
|
+
let files;
|
|
12
|
+
try {
|
|
13
|
+
files = execFileSync("git", ["ls-files", "-co", "--exclude-standard"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
|
|
14
|
+
.split("\n").filter(Boolean).slice(0, MAX_FILES);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
files = listAllFiles(MAX_FILES);
|
|
18
|
+
}
|
|
19
|
+
return files.filter((file) => {
|
|
20
|
+
try {
|
|
21
|
+
return statSync(file).size <= MAX_BYTES;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Codex's errors, with what to do about the common one: not being signed in. */
|
|
29
|
+
function explain(message) {
|
|
30
|
+
const first = message.split("\n")[0];
|
|
31
|
+
if (/401|Unauthorized|authentication|not logged in/i.test(message)) {
|
|
32
|
+
return "Codex isn't signed in. Run `codex login` (or set OPENAI_API_KEY), then try again.";
|
|
33
|
+
}
|
|
34
|
+
return first;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Codex through the Codex SDK. Codex has no per-edit approval hook: it works in its workspace sandbox, and
|
|
38
|
+
* Nou keeps a copy of the project's files at the start of each message so every change it made can be
|
|
39
|
+
* shown and undone afterwards.
|
|
40
|
+
*/
|
|
41
|
+
export class CodexEngine extends ExternalEngine {
|
|
42
|
+
id = "codex";
|
|
43
|
+
thread = null;
|
|
44
|
+
introduced = false;
|
|
45
|
+
constructor(choice, hooks) {
|
|
46
|
+
super(choice, hooks, "Codex");
|
|
47
|
+
}
|
|
48
|
+
resetConversation() {
|
|
49
|
+
this.thread = null;
|
|
50
|
+
this.introduced = false;
|
|
51
|
+
}
|
|
52
|
+
async run(prompt, signal) {
|
|
53
|
+
const { Codex } = await loadPackage("@openai/codex-sdk");
|
|
54
|
+
// Codex uses its own login (`codex login`); an OpenAI key from Nou's setup works too.
|
|
55
|
+
const apiKey = process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY;
|
|
56
|
+
this.thread ??= new Codex(apiKey ? { apiKey } : {}).startThread({
|
|
57
|
+
workingDirectory: process.cwd(),
|
|
58
|
+
skipGitRepoCheck: true,
|
|
59
|
+
sandboxMode: "workspace-write",
|
|
60
|
+
approvalPolicy: "never",
|
|
61
|
+
...(this.choice.provider.id === "openai" ? { model: this.choice.model } : {}),
|
|
62
|
+
...(this.effort && EFFORTS.has(this.effort) ? { modelReasoningEffort: this.effort } : {}),
|
|
63
|
+
});
|
|
64
|
+
// Codex has no system prompt option; Nou's project context goes with the first message.
|
|
65
|
+
const input = !this.introduced && this.extras.length ? `${this.extras.join("\n\n")}\n\n${prompt}` : prompt;
|
|
66
|
+
this.introduced = true;
|
|
67
|
+
this.tracker.remember(projectFiles());
|
|
68
|
+
let failure;
|
|
69
|
+
try {
|
|
70
|
+
const { events } = await this.thread.runStreamed(input, { signal });
|
|
71
|
+
for await (const event of events)
|
|
72
|
+
failure = (await this.handle(event)) ?? failure;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// The codex process exiting with an error ends the stream; its first line says why.
|
|
76
|
+
if (signal.aborted)
|
|
77
|
+
throw error;
|
|
78
|
+
failure ??= error.message;
|
|
79
|
+
}
|
|
80
|
+
if (signal.aborted)
|
|
81
|
+
throw new Error("aborted");
|
|
82
|
+
if (failure)
|
|
83
|
+
throw new Error(explain(failure));
|
|
84
|
+
}
|
|
85
|
+
/** Shows one Codex event; returns the error message if the turn failed. */
|
|
86
|
+
async handle(event) {
|
|
87
|
+
if (event.type === "item.updated" && event.item.type === "agent_message") {
|
|
88
|
+
this.hooks.onStream(event.item.text);
|
|
89
|
+
}
|
|
90
|
+
else if (event.type === "item.started" && event.item.type === "command_execution") {
|
|
91
|
+
this.hooks.onBusy(`Codex: ${event.item.command}`);
|
|
92
|
+
}
|
|
93
|
+
else if (event.type === "item.completed") {
|
|
94
|
+
const item = event.item;
|
|
95
|
+
if (item.type === "agent_message") {
|
|
96
|
+
this.hooks.onStream("");
|
|
97
|
+
if (item.text.trim())
|
|
98
|
+
this.hooks.onEntry({ kind: "assistant", text: item.text.trim() });
|
|
99
|
+
}
|
|
100
|
+
else if (item.type === "command_execution") {
|
|
101
|
+
this.hooks.onEntry({ kind: "tool", text: `Ran: ${item.command}${item.exit_code ? ` (exit ${item.exit_code})` : ""}` });
|
|
102
|
+
this.hooks.onBusy("Codex is working...");
|
|
103
|
+
}
|
|
104
|
+
else if (item.type === "file_change") {
|
|
105
|
+
for (const change of item.changes) {
|
|
106
|
+
if (change.kind === "delete")
|
|
107
|
+
this.hooks.onEntry({ kind: "tool", text: `Deleted ${change.path}` });
|
|
108
|
+
else
|
|
109
|
+
await this.tracker.applied(change.path, change.kind === "add");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (item.type === "web_search") {
|
|
113
|
+
this.hooks.onEntry({ kind: "tool", text: `Searched the web: ${item.query}` });
|
|
114
|
+
}
|
|
115
|
+
else if (item.type === "mcp_tool_call") {
|
|
116
|
+
this.hooks.onEntry({ kind: "tool", text: `${item.server}: ${item.tool}` });
|
|
117
|
+
}
|
|
118
|
+
else if (item.type === "error") {
|
|
119
|
+
// Warnings and retries ("Reconnecting... 2/5") show as progress, not as a stack of errors.
|
|
120
|
+
this.hooks.onBusy(`Codex: ${item.message.slice(0, 100)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (event.type === "turn.failed") {
|
|
124
|
+
return event.error.message;
|
|
125
|
+
}
|
|
126
|
+
else if (event.type === "error") {
|
|
127
|
+
return event.message;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { newSessionId } from "../agent/index.js";
|
|
4
|
+
import { isInProject, toRelative } from "../commands/index.js";
|
|
5
|
+
function readOrNull(filePath) {
|
|
6
|
+
try {
|
|
7
|
+
return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Replaces `oldText` with `newText`, as edit tools do; null if it isn't there (the tool will fail on its own). */
|
|
14
|
+
export function replaceIn(text, oldText, newText, all = false) {
|
|
15
|
+
if (!oldText || !text.includes(oldText))
|
|
16
|
+
return null;
|
|
17
|
+
return all ? text.split(oldText).join(newText) : text.replace(oldText, () => newText);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Lets an engine that edits files itself still go through Nou: approve() shows the proposed diff (or asks,
|
|
21
|
+
* when the engine's edit can't be previewed) and remembers the file as it was; applied() reads what the
|
|
22
|
+
* engine actually wrote, records it for undo and returns any problems it introduced.
|
|
23
|
+
*/
|
|
24
|
+
export class ChangeTracker {
|
|
25
|
+
hooks;
|
|
26
|
+
turnId;
|
|
27
|
+
before = new Map();
|
|
28
|
+
constructor(hooks, turnId) {
|
|
29
|
+
this.hooks = hooks;
|
|
30
|
+
this.turnId = turnId;
|
|
31
|
+
}
|
|
32
|
+
/** `after` is the file's proposed new content, or undefined when it can't be worked out in advance. */
|
|
33
|
+
async approve(filePath, after, tool) {
|
|
34
|
+
const rel = toRelative(filePath);
|
|
35
|
+
if (!isInProject(filePath))
|
|
36
|
+
return this.hooks.approve({ kind: "tool", name: tool, args: { path: filePath } });
|
|
37
|
+
const before = readOrNull(rel);
|
|
38
|
+
if (after === undefined) {
|
|
39
|
+
const ok = await this.hooks.approve({ kind: "tool", name: tool, args: { path: rel } });
|
|
40
|
+
if (ok)
|
|
41
|
+
this.before.set(rel, before);
|
|
42
|
+
return ok;
|
|
43
|
+
}
|
|
44
|
+
const change = { path: rel, before: before ?? "", after, isNew: before === null };
|
|
45
|
+
this.hooks.onChange(change, "pending", this.turnId());
|
|
46
|
+
const ok = await this.hooks.approve({ kind: "change", change });
|
|
47
|
+
if (ok)
|
|
48
|
+
this.before.set(rel, before);
|
|
49
|
+
else {
|
|
50
|
+
this.hooks.onChange(change, "rejected", this.turnId());
|
|
51
|
+
this.hooks.onEntry({ kind: "tool", text: `Rejected edit to ${rel}` });
|
|
52
|
+
}
|
|
53
|
+
return ok;
|
|
54
|
+
}
|
|
55
|
+
/** For engines that edit without asking: remember files as they are now, so changes can be shown and undone. */
|
|
56
|
+
remember(files) {
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const rel = toRelative(file);
|
|
59
|
+
if (!this.before.has(rel))
|
|
60
|
+
this.before.set(rel, readOrNull(rel));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* After the engine wrote the file: records the change and returns problems it introduced, if any.
|
|
65
|
+
* `created` marks a file the engine says it added, which won't have been remembered beforehand.
|
|
66
|
+
*/
|
|
67
|
+
async applied(filePath, created = false) {
|
|
68
|
+
const rel = toRelative(filePath);
|
|
69
|
+
if (!this.before.has(rel) && !created)
|
|
70
|
+
return null;
|
|
71
|
+
const before = this.before.get(rel) ?? null;
|
|
72
|
+
this.before.delete(rel);
|
|
73
|
+
const after = readOrNull(rel);
|
|
74
|
+
if (after === null || after === before)
|
|
75
|
+
return null;
|
|
76
|
+
const change = { path: rel, before: before ?? "", after, isNew: before === null };
|
|
77
|
+
this.hooks.onChange(change, "applied", this.turnId());
|
|
78
|
+
this.hooks.onEntry({ kind: "tool", text: `${change.isNew ? "Created" : "Edited"} ${rel}` });
|
|
79
|
+
const problems = await this.hooks.checkChange?.(change).catch(() => null);
|
|
80
|
+
if (problems)
|
|
81
|
+
this.hooks.onEntry({ kind: "error", text: `New problems in ${rel}:\n${problems}` });
|
|
82
|
+
return problems ?? null;
|
|
83
|
+
}
|
|
84
|
+
clear() {
|
|
85
|
+
this.before.clear();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** What engines that run their own agent loop share: turn bookkeeping, notes, cancelling and error reporting. */
|
|
89
|
+
export class ExternalEngine {
|
|
90
|
+
choice;
|
|
91
|
+
hooks;
|
|
92
|
+
label;
|
|
93
|
+
turn = 0;
|
|
94
|
+
session = newSessionId();
|
|
95
|
+
extras = [];
|
|
96
|
+
effort;
|
|
97
|
+
notes = [];
|
|
98
|
+
controller = null;
|
|
99
|
+
tracker;
|
|
100
|
+
constructor(choice, hooks, label) {
|
|
101
|
+
this.choice = choice;
|
|
102
|
+
this.hooks = hooks;
|
|
103
|
+
this.label = label;
|
|
104
|
+
this.tracker = new ChangeTracker(hooks, () => this.turnId);
|
|
105
|
+
}
|
|
106
|
+
get turnId() {
|
|
107
|
+
return `${this.session}:${this.turn}`;
|
|
108
|
+
}
|
|
109
|
+
configure(options) {
|
|
110
|
+
if (options.extras)
|
|
111
|
+
this.extras = options.extras;
|
|
112
|
+
if (options.effort)
|
|
113
|
+
this.effort = options.effort;
|
|
114
|
+
}
|
|
115
|
+
setModel(choice) {
|
|
116
|
+
this.choice = choice;
|
|
117
|
+
this.resetConversation();
|
|
118
|
+
}
|
|
119
|
+
clear() {
|
|
120
|
+
this.notes = [];
|
|
121
|
+
this.resetConversation();
|
|
122
|
+
}
|
|
123
|
+
note(text) {
|
|
124
|
+
this.notes.push(text);
|
|
125
|
+
}
|
|
126
|
+
cancel() {
|
|
127
|
+
this.controller?.abort();
|
|
128
|
+
}
|
|
129
|
+
async send(text, context) {
|
|
130
|
+
this.turn++;
|
|
131
|
+
const notes = this.notes.length ? `${this.notes.map((n) => `[Note: ${n}]`).join("\n")}\n\n` : "";
|
|
132
|
+
this.notes = [];
|
|
133
|
+
this.controller = new AbortController();
|
|
134
|
+
const { signal } = this.controller;
|
|
135
|
+
this.hooks.onBusy(`Working with ${this.label}...`);
|
|
136
|
+
try {
|
|
137
|
+
await this.run(`${notes}${text}${context ? `\n\n${context}` : ""}`, signal);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (signal.aborted)
|
|
141
|
+
this.hooks.onEntry({ kind: "info", text: "Cancelled." });
|
|
142
|
+
else
|
|
143
|
+
this.hooks.onEntry({ kind: "error", text: `${this.label}: ${error.message}` });
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
this.hooks.onStream("");
|
|
147
|
+
this.tracker.clear();
|
|
148
|
+
this.controller = null;
|
|
149
|
+
this.hooks.onBusy(null);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
5
|
+
import { Agent } from "../agent/index.js";
|
|
6
|
+
import { NOU_HOME } from "../config/index.js";
|
|
7
|
+
import { ClaudeCodeEngine } from "./claude.js";
|
|
8
|
+
import { CodexEngine } from "./codex.js";
|
|
9
|
+
import { PiEngine } from "./pi.js";
|
|
10
|
+
import { ENGINE_IDS } from "./types.js";
|
|
11
|
+
export * from "./types.js";
|
|
12
|
+
/** Where optional engines are installed (`npm install --prefix`), so Nou itself stays small. */
|
|
13
|
+
export const ENGINE_DIR = process.env.NOU_ENGINE_DIR || path.join(NOU_HOME, "engines");
|
|
14
|
+
export const ENGINES = [
|
|
15
|
+
{
|
|
16
|
+
id: "nou",
|
|
17
|
+
label: "Nou",
|
|
18
|
+
description: "Nou's own agent. Works with every model, local or cloud.",
|
|
19
|
+
approvals: "before",
|
|
20
|
+
model: (choice) => `${choice.provider.id}:${choice.model}`,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "claude-code",
|
|
24
|
+
label: "Claude Code",
|
|
25
|
+
description: "Anthropic's agent (Claude Agent SDK). Claude models; uses your API key or Claude login.",
|
|
26
|
+
package: "@anthropic-ai/claude-agent-sdk",
|
|
27
|
+
approvals: "before",
|
|
28
|
+
model: (choice) => (choice.provider.id === "anthropic" ? choice.model : "Claude Code's default model"),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "codex",
|
|
32
|
+
label: "Codex",
|
|
33
|
+
description: "OpenAI's agent (Codex SDK). OpenAI models; uses your API key or ChatGPT login. Edits in a sandbox without asking; review and undo after.",
|
|
34
|
+
package: "@openai/codex-sdk",
|
|
35
|
+
approvals: "after",
|
|
36
|
+
model: (choice) => (choice.provider.id === "openai" ? choice.model : "Codex's default model"),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "pi",
|
|
40
|
+
label: "pi",
|
|
41
|
+
description: "The minimal pi coding agent. Works with your Nou model, local or cloud.",
|
|
42
|
+
package: "@earendil-works/pi-coding-agent",
|
|
43
|
+
approvals: "before",
|
|
44
|
+
model: (choice) => `${choice.provider.id}:${choice.model}`,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
export const engineInfo = (id) => ENGINES.find((e) => e.id === id);
|
|
48
|
+
export const isEngineId = (id) => ENGINE_IDS.includes(id);
|
|
49
|
+
/** Where a package's ESM entry point is, under `node_modules` of `base`, or null if it isn't installed there. */
|
|
50
|
+
function entryPoint(base, pkg) {
|
|
51
|
+
const dir = path.join(base, "node_modules", ...pkg.split("/"));
|
|
52
|
+
const manifest = path.join(dir, "package.json");
|
|
53
|
+
if (!existsSync(manifest))
|
|
54
|
+
return null;
|
|
55
|
+
const json = JSON.parse(readFileSync(manifest, "utf-8"));
|
|
56
|
+
let entry = json.exports?.["."] ?? json.exports ?? json.module ?? json.main ?? "index.js";
|
|
57
|
+
// Conditional exports: prefer import, then default (some nest them, e.g. { import: { default } }).
|
|
58
|
+
while (entry && typeof entry === "object")
|
|
59
|
+
entry = entry.import ?? entry.default ?? entry.node;
|
|
60
|
+
return typeof entry === "string" ? path.join(dir, entry) : null;
|
|
61
|
+
}
|
|
62
|
+
/** Nou's own package folder (for engines installed as ordinary dependencies, e.g. in development). */
|
|
63
|
+
function ownDir() {
|
|
64
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
65
|
+
for (let i = 0; i < 5 && !existsSync(path.join(dir, "package.json")); i++)
|
|
66
|
+
dir = path.dirname(dir);
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
function findPackage(pkg) {
|
|
70
|
+
return entryPoint(ENGINE_DIR, pkg) ?? entryPoint(ownDir(), pkg);
|
|
71
|
+
}
|
|
72
|
+
export const isInstalled = (info) => !info.package || findPackage(info.package) !== null;
|
|
73
|
+
/** Imports an engine's package; it must be installed (see installEngine). */
|
|
74
|
+
export async function loadPackage(pkg) {
|
|
75
|
+
const entry = findPackage(pkg);
|
|
76
|
+
if (!entry)
|
|
77
|
+
throw new Error(`${pkg} isn't installed. Pick the engine again in /settings to install it.`);
|
|
78
|
+
return import(pathToFileURL(entry).href);
|
|
79
|
+
}
|
|
80
|
+
/** Installs an engine's package into ~/.nou/engines. `onLine` gets npm's output as it goes. */
|
|
81
|
+
export function installEngine(info, onLine, signal) {
|
|
82
|
+
if (!info.package)
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
mkdirSync(ENGINE_DIR, { recursive: true });
|
|
85
|
+
const manifest = path.join(ENGINE_DIR, "package.json");
|
|
86
|
+
if (!existsSync(manifest))
|
|
87
|
+
writeFileSync(manifest, JSON.stringify({ name: "nou-engines", private: true }, null, 2));
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const child = spawn("npm", ["install", "--prefix", ENGINE_DIR, "--no-audit", "--no-fund", info.package], { shell: process.platform === "win32" });
|
|
90
|
+
const onAbort = () => child.kill();
|
|
91
|
+
signal?.addEventListener("abort", onAbort);
|
|
92
|
+
let tail = "";
|
|
93
|
+
const read = (data) => {
|
|
94
|
+
for (const line of data.toString().split(/\r?\n/)) {
|
|
95
|
+
if (!line.trim())
|
|
96
|
+
continue;
|
|
97
|
+
tail = line;
|
|
98
|
+
onLine(line.trim());
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
child.stdout.on("data", read);
|
|
102
|
+
child.stderr.on("data", read);
|
|
103
|
+
child.on("error", reject);
|
|
104
|
+
child.on("close", (code) => {
|
|
105
|
+
signal?.removeEventListener("abort", onAbort);
|
|
106
|
+
if (signal?.aborted)
|
|
107
|
+
reject(new Error("stopped"));
|
|
108
|
+
else if (code === 0)
|
|
109
|
+
resolve();
|
|
110
|
+
else
|
|
111
|
+
reject(new Error(`npm install failed (exit ${code}): ${tail}`));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Creates the engine; external ones load their package when they first run. */
|
|
116
|
+
export function createEngine(id, choice, system, hooks) {
|
|
117
|
+
switch (id) {
|
|
118
|
+
case "claude-code":
|
|
119
|
+
return new ClaudeCodeEngine(choice, hooks);
|
|
120
|
+
case "codex":
|
|
121
|
+
return new CodexEngine(choice, hooks);
|
|
122
|
+
case "pi":
|
|
123
|
+
return new PiEngine(choice, hooks);
|
|
124
|
+
default:
|
|
125
|
+
return new Agent(choice, system, hooks);
|
|
126
|
+
}
|
|
127
|
+
}
|