@nawc/cli 0.1.1
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/LICENSE +661 -0
- package/README.md +13 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +210 -0
- package/dist/index.d.mts +26 -0
- package/dist/index.mjs +15 -0
- package/dist/server-B0NGps95.mjs +1646 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { syntaxFor } from "@nawc/config";
|
|
3
|
+
import { vscode } from "@nawc/editor-vscode";
|
|
4
|
+
import { nawcLight } from "@nawc/theme-nawc";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { access, mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { getRequestListener } from "@hono/node-server";
|
|
11
|
+
import { watch } from "chokidar";
|
|
12
|
+
import { Hono } from "hono";
|
|
13
|
+
import { streamSSE } from "hono/streaming";
|
|
14
|
+
import { createJiti } from "jiti";
|
|
15
|
+
import { spawn } from "node-pty";
|
|
16
|
+
import { createServer as createServer$1 } from "vite";
|
|
17
|
+
import { WebSocketServer } from "ws";
|
|
18
|
+
import { execFile, spawn as spawn$1 } from "node:child_process";
|
|
19
|
+
import { promisify } from "node:util";
|
|
20
|
+
import MiniSearch from "minisearch";
|
|
21
|
+
import { parseFragment } from "parse5";
|
|
22
|
+
import { mkdirSync } from "node:fs";
|
|
23
|
+
import { DatabaseSync } from "node:sqlite";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
//#region src/workspace.ts
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
const GENERATED_DIRECTORY_NAMES = /* @__PURE__ */ new Set([
|
|
28
|
+
".git",
|
|
29
|
+
".nawc",
|
|
30
|
+
".skills",
|
|
31
|
+
"build",
|
|
32
|
+
"coverage",
|
|
33
|
+
"dist",
|
|
34
|
+
"node_modules"
|
|
35
|
+
]);
|
|
36
|
+
const PROJECT_FILE_CACHE_TTL_MS = 2e3;
|
|
37
|
+
const projectFileCache = /* @__PURE__ */ new Map();
|
|
38
|
+
async function safePath(root, relative) {
|
|
39
|
+
if (path.isAbsolute(relative)) throw new Error("Paths must be relative");
|
|
40
|
+
const resolvedRoot = path.resolve(root);
|
|
41
|
+
const target = path.resolve(resolvedRoot, relative);
|
|
42
|
+
if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error("Path escapes the configured directory");
|
|
43
|
+
return target;
|
|
44
|
+
}
|
|
45
|
+
async function safeExistingPath(root, relative) {
|
|
46
|
+
const target = await realpath(await safePath(root, relative));
|
|
47
|
+
const resolvedRoot = await realpath(root);
|
|
48
|
+
if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error("Path escapes the configured directory through a symbolic link");
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
async function listNotes(srcDir) {
|
|
52
|
+
const notes = [];
|
|
53
|
+
const walk = async (directory) => {
|
|
54
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
55
|
+
const file = path.join(directory, entry.name);
|
|
56
|
+
if (entry.isDirectory()) await walk(file);
|
|
57
|
+
else if (entry.isFile() && entry.name.endsWith(".html")) notes.push(path.relative(srcDir, file));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
await mkdir(srcDir, { recursive: true });
|
|
61
|
+
await walk(srcDir);
|
|
62
|
+
return notes.sort();
|
|
63
|
+
}
|
|
64
|
+
async function listEntries(srcDir) {
|
|
65
|
+
const entries = [];
|
|
66
|
+
const walk = async (directory) => {
|
|
67
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
68
|
+
const file = path.join(directory, entry.name);
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
entries.push({
|
|
71
|
+
path: path.relative(srcDir, file),
|
|
72
|
+
type: "folder"
|
|
73
|
+
});
|
|
74
|
+
await walk(file);
|
|
75
|
+
} else if (entry.isFile() && entry.name.endsWith(".html")) entries.push({
|
|
76
|
+
path: path.relative(srcDir, file),
|
|
77
|
+
type: "file"
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
await mkdir(srcDir, { recursive: true });
|
|
82
|
+
await walk(srcDir);
|
|
83
|
+
return entries.sort((left, right) => left.path.localeCompare(right.path));
|
|
84
|
+
}
|
|
85
|
+
async function projectFileCandidates(baseDir) {
|
|
86
|
+
const cacheKey = path.resolve(baseDir);
|
|
87
|
+
const cached = projectFileCache.get(cacheKey);
|
|
88
|
+
if (cached && cached.expiresAt > Date.now()) return cached.files;
|
|
89
|
+
const { stdout } = await execFileAsync("git", [
|
|
90
|
+
"-C",
|
|
91
|
+
baseDir,
|
|
92
|
+
"ls-files",
|
|
93
|
+
"-co",
|
|
94
|
+
"--exclude-standard",
|
|
95
|
+
"-z",
|
|
96
|
+
"--",
|
|
97
|
+
"."
|
|
98
|
+
], {
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
maxBuffer: 16 * 1024 * 1024
|
|
101
|
+
});
|
|
102
|
+
const files = stdout.split("\0").filter(Boolean).filter((file) => !file.split(/[\\/]/).some((segment) => GENERATED_DIRECTORY_NAMES.has(segment))).sort();
|
|
103
|
+
projectFileCache.set(cacheKey, {
|
|
104
|
+
expiresAt: Date.now() + PROJECT_FILE_CACHE_TTL_MS,
|
|
105
|
+
files
|
|
106
|
+
});
|
|
107
|
+
return files;
|
|
108
|
+
}
|
|
109
|
+
async function projectPathCandidates(baseDir) {
|
|
110
|
+
const files = await projectFileCandidates(baseDir);
|
|
111
|
+
const paths = new Set(files);
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
const segments = file.split("/");
|
|
114
|
+
for (let index = 1; index < segments.length; index++) paths.add(segments.slice(0, index).join("/"));
|
|
115
|
+
}
|
|
116
|
+
return [...paths].sort();
|
|
117
|
+
}
|
|
118
|
+
/** Lists searchable project files and their containing directories. */
|
|
119
|
+
async function listProjectPaths(baseDir, options = {}) {
|
|
120
|
+
const query = options.query?.trim().toLowerCase();
|
|
121
|
+
const limit = options.limit === void 0 ? void 0 : Math.max(0, options.limit);
|
|
122
|
+
const candidates = (await projectPathCandidates(baseDir)).filter((candidate) => !query || candidate.toLowerCase().includes(query)).slice(0, limit === void 0 ? void 0 : limit * 2);
|
|
123
|
+
return (await Promise.all(candidates.map(async (candidate) => {
|
|
124
|
+
try {
|
|
125
|
+
return {
|
|
126
|
+
path: candidate,
|
|
127
|
+
kind: (await stat(await safeExistingPath(baseDir, candidate))).isDirectory() ? "directory" : "file"
|
|
128
|
+
};
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}))).filter((entry) => entry !== void 0).slice(0, limit);
|
|
133
|
+
}
|
|
134
|
+
/** Validates one submitted project path, including directories. */
|
|
135
|
+
async function isProjectPath(baseDir, file) {
|
|
136
|
+
if (!(await projectPathCandidates(baseDir)).includes(file)) return false;
|
|
137
|
+
try {
|
|
138
|
+
await safeExistingPath(baseDir, file);
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function readNote(srcDir, note) {
|
|
145
|
+
return readFile(await safePath(srcDir, note), "utf8");
|
|
146
|
+
}
|
|
147
|
+
async function writeNote(srcDir, note, content) {
|
|
148
|
+
if (!note.endsWith(".html")) throw new Error("Notes must use the .html extension");
|
|
149
|
+
const file = await safePath(srcDir, note);
|
|
150
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
151
|
+
await writeFile(file, content, "utf8");
|
|
152
|
+
}
|
|
153
|
+
async function deleteNote(srcDir, note) {
|
|
154
|
+
await rm(await safePath(srcDir, note));
|
|
155
|
+
}
|
|
156
|
+
async function createFolder(srcDir, folder) {
|
|
157
|
+
await mkdir(await safePath(srcDir, folder), { recursive: false });
|
|
158
|
+
}
|
|
159
|
+
async function deleteEntry(srcDir, entry) {
|
|
160
|
+
await rm(await safePath(srcDir, entry), { recursive: true });
|
|
161
|
+
}
|
|
162
|
+
async function renameEntry(srcDir, from, to) {
|
|
163
|
+
const source = await safePath(srcDir, from);
|
|
164
|
+
const target = await safePath(srcDir, to);
|
|
165
|
+
try {
|
|
166
|
+
await access(target);
|
|
167
|
+
throw new Error(`An entry already exists at ${to}`);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
170
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
171
|
+
await rename(source, target);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function moveEntry(srcDir, from, to, replace = false) {
|
|
178
|
+
const source = await safePath(srcDir, from);
|
|
179
|
+
const target = await safePath(srcDir, to);
|
|
180
|
+
if (source === target) return;
|
|
181
|
+
if (target.startsWith(`${source}${path.sep}`)) throw new Error("A folder cannot be moved inside itself");
|
|
182
|
+
try {
|
|
183
|
+
await access(target);
|
|
184
|
+
if (!replace) throw new Error(`An entry already exists at ${to}`);
|
|
185
|
+
await rm(target, { recursive: true });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
188
|
+
}
|
|
189
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
190
|
+
await rename(source, target);
|
|
191
|
+
}
|
|
192
|
+
async function renameNote(srcDir, from, to) {
|
|
193
|
+
if (!to.endsWith(".html")) throw new Error("Notes must use the .html extension");
|
|
194
|
+
const target = await safePath(srcDir, to);
|
|
195
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
196
|
+
await rename(await safePath(srcDir, from), target);
|
|
197
|
+
}
|
|
198
|
+
async function resolveSource(config, baseDir, selection) {
|
|
199
|
+
const source = await readFile(await safePath(baseDir, selection.file), "utf8");
|
|
200
|
+
if (!selection.syntax) return {
|
|
201
|
+
...selection,
|
|
202
|
+
code: source,
|
|
203
|
+
startLine: 1,
|
|
204
|
+
endLine: source.split("\n").length
|
|
205
|
+
};
|
|
206
|
+
const syntax = syntaxFor(config, selection.syntax);
|
|
207
|
+
if (!syntax) throw new Error(`Unknown syntax: ${selection.syntax}`);
|
|
208
|
+
const resolved = syntax.resolve(source, selection);
|
|
209
|
+
if (!resolved) throw new Error(`Could not find ${selection.type ?? "symbol"} ${selection.name ?? ""}${selection.params === void 0 ? "" : `(${selection.params})`} in ${selection.file}`);
|
|
210
|
+
return resolved;
|
|
211
|
+
}
|
|
212
|
+
async function assertGitRepository(baseDir) {
|
|
213
|
+
let current = await realpath(baseDir);
|
|
214
|
+
while (true) try {
|
|
215
|
+
await realpath(path.join(current, ".git"));
|
|
216
|
+
return;
|
|
217
|
+
} catch {
|
|
218
|
+
const parent = path.dirname(current);
|
|
219
|
+
if (parent === current) throw new Error(`NAWC baseDir must be inside a Git repository: ${baseDir}`);
|
|
220
|
+
current = parent;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/skills.ts
|
|
225
|
+
const legacyHeader = "<!-- Generated by NAWC. Changes will be replaced. -->\n\n";
|
|
226
|
+
const generatedHeader = "---\n# Generated by NAWC. Changes will be replaced.\n";
|
|
227
|
+
function isGeneratedSkill(content) {
|
|
228
|
+
return content.startsWith(legacyHeader) || content.startsWith(generatedHeader);
|
|
229
|
+
}
|
|
230
|
+
/** Canonicalize skill markdown so generation matches oxfmt (lists, spacing). */
|
|
231
|
+
function normalizeSkillMarkdown(content) {
|
|
232
|
+
const lines = content.split("\n").map((line) => line.replace(/^(\s*)\*(\s+)/, "$1-$2"));
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
const prev = out.at(-1);
|
|
236
|
+
const isList = /^(\s*)([-*+]|\d+\.)\s/.test(line);
|
|
237
|
+
const prevIsList = prev !== void 0 && /^(\s*)([-*+]|\d+\.)\s/.test(prev);
|
|
238
|
+
const prevBlank = prev === void 0 || prev === "";
|
|
239
|
+
const prevIsFence = prev !== void 0 && /^`{3}/.test(prev);
|
|
240
|
+
const prevIsAtxHeading = prev !== void 0 && /^#{1,6}(\s|$)/.test(prev);
|
|
241
|
+
if (isList && prev !== void 0 && !prevBlank && !prevIsList && !prevIsFence && !prevIsAtxHeading) out.push("");
|
|
242
|
+
out.push(line);
|
|
243
|
+
}
|
|
244
|
+
return out.join("\n");
|
|
245
|
+
}
|
|
246
|
+
function renderGeneratedSkill(content) {
|
|
247
|
+
const trimmed = content.trim();
|
|
248
|
+
return normalizeSkillMarkdown(trimmed.startsWith("---") ? `${generatedHeader}${trimmed.slice(3).replace(/^\n/, "")}\n` : `${legacyHeader}${trimmed}\n`);
|
|
249
|
+
}
|
|
250
|
+
async function syncSkills(projectDir, plugins) {
|
|
251
|
+
const skillsDir = path.join(projectDir, ".skills");
|
|
252
|
+
const generatedSkills = /* @__PURE__ */ new Set();
|
|
253
|
+
for (const plugin of plugins) for (const skill of plugin.skills ?? []) {
|
|
254
|
+
generatedSkills.add(skill.name);
|
|
255
|
+
const directory = path.join(skillsDir, skill.name);
|
|
256
|
+
const file = path.join(directory, "SKILL.md");
|
|
257
|
+
await mkdir(directory, { recursive: true });
|
|
258
|
+
try {
|
|
259
|
+
if (!isGeneratedSkill(await readFile(file, "utf8"))) throw new Error(`Refusing to overwrite user-owned skill: ${file}`);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error instanceof Error && !error.message.includes("ENOENT")) throw error;
|
|
262
|
+
}
|
|
263
|
+
await writeFile(file, renderGeneratedSkill(skill.content), "utf8");
|
|
264
|
+
}
|
|
265
|
+
for (const entry of await readdir(skillsDir, { withFileTypes: true })) {
|
|
266
|
+
if (!entry.isDirectory() || generatedSkills.has(entry.name)) continue;
|
|
267
|
+
const file = path.join(skillsDir, entry.name, "SKILL.md");
|
|
268
|
+
try {
|
|
269
|
+
if (!isGeneratedSkill(await readFile(file, "utf8"))) continue;
|
|
270
|
+
await rm(file);
|
|
271
|
+
await rmdir(path.join(skillsDir, entry.name));
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
274
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY") throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return skillsDir;
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/run-protocol.ts
|
|
281
|
+
function isRecord(value) {
|
|
282
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
283
|
+
}
|
|
284
|
+
function isOptionalString(value) {
|
|
285
|
+
return value === void 0 || typeof value === "string";
|
|
286
|
+
}
|
|
287
|
+
function isSelection(value) {
|
|
288
|
+
return isRecord(value) && (typeof value.file === "string" && value.file.length > 0 || typeof value.source === "string" && value.source.length <= 1e6) && isOptionalString(value.source) && (value.source === void 0 || value.source.length <= 1e6) && isOptionalString(value.syntax) && isOptionalString(value.name) && isOptionalString(value.type) && isOptionalString(value.params);
|
|
289
|
+
}
|
|
290
|
+
function isDimension(value) {
|
|
291
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0;
|
|
292
|
+
}
|
|
293
|
+
function parseRunClientEvent(value) {
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(value);
|
|
297
|
+
} catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!isRecord(parsed) || typeof parsed.type !== "string") return;
|
|
301
|
+
if (parsed.type === "start" && isSelection(parsed.selection) && isDimension(parsed.cols) && isDimension(parsed.rows)) return {
|
|
302
|
+
type: "start",
|
|
303
|
+
selection: parsed.selection,
|
|
304
|
+
cols: parsed.cols,
|
|
305
|
+
rows: parsed.rows
|
|
306
|
+
};
|
|
307
|
+
if (parsed.type === "input" && typeof parsed.data === "string") return {
|
|
308
|
+
type: "input",
|
|
309
|
+
data: parsed.data
|
|
310
|
+
};
|
|
311
|
+
if (parsed.type === "resize" && isDimension(parsed.cols) && isDimension(parsed.rows)) return {
|
|
312
|
+
type: "resize",
|
|
313
|
+
cols: parsed.cols,
|
|
314
|
+
rows: parsed.rows
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function isSameOrigin(origin, host, allowRemote = false) {
|
|
318
|
+
if (!origin || !host) return false;
|
|
319
|
+
try {
|
|
320
|
+
const configured = new URL(`http://${host}`);
|
|
321
|
+
if (!allowRemote && configured.hostname !== "localhost" && configured.hostname !== "127.0.0.1" && configured.hostname !== "::1") return false;
|
|
322
|
+
const requested = new URL(origin);
|
|
323
|
+
return (requested.protocol === "http:" || requested.protocol === "https:") && requested.host === configured.host;
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function isPreviewRequest(origin, host) {
|
|
329
|
+
return origin === "null" || host?.startsWith("127.0.0.1:") === true;
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/editor.ts
|
|
333
|
+
const CLI_INSTALL_HINTS = {
|
|
334
|
+
vscode: "Open the Command Palette and run 'Shell Command: Install code command in PATH'.",
|
|
335
|
+
idea: "In the IDE use Tools → Create Command-line Launcher, or add the install's bin directory to your PATH. Toolbox users: Settings → Generate shell scripts.",
|
|
336
|
+
webstorm: "In the IDE use Tools → Create Command-line Launcher, or add the install's bin directory to your PATH. Toolbox users: Settings → Generate shell scripts.",
|
|
337
|
+
clion: "In the IDE use Tools → Create Command-line Launcher, or add the install's bin directory to your PATH. Toolbox users: Settings → Generate shell scripts.",
|
|
338
|
+
zed: "Install the Zed CLI: open Zed → Command Palette → 'cli: install cli binary'.",
|
|
339
|
+
cursor: "Open the Command Palette and run 'Shell Command: Install cursor command in PATH'."
|
|
340
|
+
};
|
|
341
|
+
function installHint(editorName) {
|
|
342
|
+
return CLI_INSTALL_HINTS[editorName] ?? "Make sure the editor's CLI is installed and available on your PATH.";
|
|
343
|
+
}
|
|
344
|
+
function commandNotFoundError(editor, command) {
|
|
345
|
+
return /* @__PURE__ */ new Error(`Could not find the "${command}" command for ${editor.label}. ${installHint(editor.name)}`);
|
|
346
|
+
}
|
|
347
|
+
function urlOpenCommand(url, platform = process.platform) {
|
|
348
|
+
if (platform === "darwin") return ["open", url];
|
|
349
|
+
if (platform === "win32") return [
|
|
350
|
+
"rundll32.exe",
|
|
351
|
+
"url.dll,FileProtocolHandler",
|
|
352
|
+
url
|
|
353
|
+
];
|
|
354
|
+
return ["xdg-open", url];
|
|
355
|
+
}
|
|
356
|
+
async function launchEditor(editor, location) {
|
|
357
|
+
const target = editor.open(location);
|
|
358
|
+
const [command, ...args] = target.type === "url" ? urlOpenCommand(target.url) : target.command;
|
|
359
|
+
if (!command) throw new Error(`Editor ${editor.name} returned an empty command`);
|
|
360
|
+
const child = spawn$1(command, args, {
|
|
361
|
+
detached: target.type === "command",
|
|
362
|
+
stdio: "ignore"
|
|
363
|
+
});
|
|
364
|
+
if (target.type === "url") {
|
|
365
|
+
await new Promise((resolve, reject) => {
|
|
366
|
+
child.once("error", (err) => {
|
|
367
|
+
if (err.code === "ENOENT") reject(commandNotFoundError(editor, command));
|
|
368
|
+
else reject(err);
|
|
369
|
+
});
|
|
370
|
+
child.once("close", (code) => {
|
|
371
|
+
if (code === 0) resolve();
|
|
372
|
+
else reject(/* @__PURE__ */ new Error(`Could not open ${editor.label} (launcher exited with code ${code})`));
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
await new Promise((resolve, reject) => {
|
|
378
|
+
let settled = false;
|
|
379
|
+
const settle = (fn) => {
|
|
380
|
+
if (settled) return;
|
|
381
|
+
settled = true;
|
|
382
|
+
fn();
|
|
383
|
+
};
|
|
384
|
+
child.once("error", (err) => {
|
|
385
|
+
settle(() => {
|
|
386
|
+
if (err.code === "ENOENT") reject(commandNotFoundError(editor, command));
|
|
387
|
+
else reject(err);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
child.once("spawn", () => settle(() => resolve()));
|
|
391
|
+
});
|
|
392
|
+
child.unref();
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/note-search.ts
|
|
396
|
+
function noteTitle(notePath) {
|
|
397
|
+
return path.basename(notePath, ".html");
|
|
398
|
+
}
|
|
399
|
+
function htmlText(html) {
|
|
400
|
+
const root = parseFragment(html);
|
|
401
|
+
const parts = [];
|
|
402
|
+
const visit = (node, ignored = false) => {
|
|
403
|
+
const skip = ignored || node.nodeName === "script" || node.nodeName === "style";
|
|
404
|
+
if (!skip && node.nodeName === "#text" && node.value) parts.push(node.value);
|
|
405
|
+
for (const child of node.childNodes ?? []) visit(child, skip);
|
|
406
|
+
};
|
|
407
|
+
visit(root);
|
|
408
|
+
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
409
|
+
}
|
|
410
|
+
function snippet(text, terms, maximumLength = 150) {
|
|
411
|
+
if (text.length <= maximumLength) return text;
|
|
412
|
+
const lower = text.toLowerCase();
|
|
413
|
+
const positions = terms.map((term) => lower.indexOf(term.toLowerCase())).filter((position) => position >= 0);
|
|
414
|
+
const match = positions.length ? Math.min(...positions) : 0;
|
|
415
|
+
const start = Math.max(0, Math.min(match - 45, text.length - maximumLength));
|
|
416
|
+
const beginning = start > 0 ? "…" : "";
|
|
417
|
+
const ending = start + maximumLength < text.length ? "…" : "";
|
|
418
|
+
return `${beginning}${text.slice(start, start + maximumLength).trim()}${ending}`;
|
|
419
|
+
}
|
|
420
|
+
function createIndex() {
|
|
421
|
+
return new MiniSearch({
|
|
422
|
+
fields: [
|
|
423
|
+
"title",
|
|
424
|
+
"path",
|
|
425
|
+
"text"
|
|
426
|
+
],
|
|
427
|
+
idField: "path",
|
|
428
|
+
storeFields: [
|
|
429
|
+
"path",
|
|
430
|
+
"title",
|
|
431
|
+
"text"
|
|
432
|
+
],
|
|
433
|
+
searchOptions: {
|
|
434
|
+
boost: {
|
|
435
|
+
title: 6,
|
|
436
|
+
path: 4,
|
|
437
|
+
text: 1
|
|
438
|
+
},
|
|
439
|
+
combineWith: "OR",
|
|
440
|
+
fuzzy: .2,
|
|
441
|
+
prefix: (_term, index, terms) => index === terms.length - 1
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
var NoteSearchIndex = class {
|
|
446
|
+
#srcDir;
|
|
447
|
+
#dirty = true;
|
|
448
|
+
#revision = 0;
|
|
449
|
+
#index = createIndex();
|
|
450
|
+
#rebuild;
|
|
451
|
+
#recencyBoost = /* @__PURE__ */ new Map();
|
|
452
|
+
constructor(srcDir) {
|
|
453
|
+
this.#srcDir = srcDir;
|
|
454
|
+
}
|
|
455
|
+
invalidate() {
|
|
456
|
+
this.#dirty = true;
|
|
457
|
+
this.#revision += 1;
|
|
458
|
+
}
|
|
459
|
+
async #ensureFresh() {
|
|
460
|
+
while (this.#dirty) {
|
|
461
|
+
if (!this.#rebuild) {
|
|
462
|
+
const revision = this.#revision;
|
|
463
|
+
this.#rebuild = (async () => {
|
|
464
|
+
const notes = await listNotes(this.#srcDir);
|
|
465
|
+
const entries = await Promise.all(notes.map(async (notePath) => {
|
|
466
|
+
const fullPath = path.join(this.#srcDir, notePath);
|
|
467
|
+
const [html, fileStat] = await Promise.all([readFile(fullPath, "utf8"), stat(fullPath)]);
|
|
468
|
+
const daysOld = (Date.now() - fileStat.mtimeMs) / (1e3 * 60 * 60 * 24);
|
|
469
|
+
const boost = 1 + .5 * Math.exp(-daysOld / 14);
|
|
470
|
+
return {
|
|
471
|
+
doc: {
|
|
472
|
+
path: notePath,
|
|
473
|
+
title: noteTitle(notePath),
|
|
474
|
+
text: htmlText(html)
|
|
475
|
+
},
|
|
476
|
+
boost
|
|
477
|
+
};
|
|
478
|
+
}));
|
|
479
|
+
const next = createIndex();
|
|
480
|
+
next.addAll(entries.map((e) => e.doc));
|
|
481
|
+
this.#index = next;
|
|
482
|
+
this.#recencyBoost = new Map(entries.map((e) => [e.doc.path, e.boost]));
|
|
483
|
+
this.#dirty = revision !== this.#revision;
|
|
484
|
+
})().finally(() => {
|
|
485
|
+
this.#rebuild = void 0;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
await this.#rebuild;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async search(query, limit = 30) {
|
|
492
|
+
await this.#ensureFresh();
|
|
493
|
+
const normalized = query.trim();
|
|
494
|
+
if (!normalized) return [];
|
|
495
|
+
return this.#index.search(normalized, { boostDocument: (docId) => this.#recencyBoost.get(docId) ?? 1 }).slice(0, limit).map((result) => {
|
|
496
|
+
const note = result;
|
|
497
|
+
return {
|
|
498
|
+
path: note.path,
|
|
499
|
+
title: note.title,
|
|
500
|
+
snippet: snippet(note.text, result.terms),
|
|
501
|
+
terms: result.terms
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/agent-references.ts
|
|
508
|
+
function promptReferenceKey(reference) {
|
|
509
|
+
switch (reference.type) {
|
|
510
|
+
case "file": return `file:${reference.path}`;
|
|
511
|
+
case "skill": return `skill:${reference.name}`;
|
|
512
|
+
case "note": return `note:${reference.path}`;
|
|
513
|
+
case "diagnostic": return `diagnostic:${reference.file ?? ""}:${reference.line ?? ""}:${reference.message}`;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** Drop bulky note bodies from thread message metadata (UI only shows path chips). */
|
|
517
|
+
function displayReference(reference) {
|
|
518
|
+
if (reference.type === "note") return {
|
|
519
|
+
type: "note",
|
|
520
|
+
path: reference.path
|
|
521
|
+
};
|
|
522
|
+
return reference;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Full content (notes) and structured attach lines are injected once per thread.
|
|
526
|
+
* Later turns keep:
|
|
527
|
+
* - notes as path-only (current note still known)
|
|
528
|
+
* - files/skills/diagnostics omitted (already in provider history; user text still has @/$ etc.)
|
|
529
|
+
*/
|
|
530
|
+
function prepareTurnReferences(references, attachedReferenceKeys) {
|
|
531
|
+
const attached = new Set(attachedReferenceKeys);
|
|
532
|
+
const prepared = [];
|
|
533
|
+
for (const reference of references) {
|
|
534
|
+
const key = promptReferenceKey(reference);
|
|
535
|
+
if (attached.has(key)) {
|
|
536
|
+
if (reference.type === "note") prepared.push({
|
|
537
|
+
type: "note",
|
|
538
|
+
path: reference.path
|
|
539
|
+
});
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
attached.add(key);
|
|
543
|
+
attachedReferenceKeys.push(key);
|
|
544
|
+
prepared.push(reference);
|
|
545
|
+
}
|
|
546
|
+
return prepared;
|
|
547
|
+
}
|
|
548
|
+
//#endregion
|
|
549
|
+
//#region src/agent-thread.ts
|
|
550
|
+
const timestamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
551
|
+
function createAgentThread(provider, id = randomUUID()) {
|
|
552
|
+
const now = timestamp();
|
|
553
|
+
return {
|
|
554
|
+
id,
|
|
555
|
+
provider,
|
|
556
|
+
createdAt: now,
|
|
557
|
+
updatedAt: now,
|
|
558
|
+
status: "idle",
|
|
559
|
+
turns: [],
|
|
560
|
+
messages: [],
|
|
561
|
+
activities: [],
|
|
562
|
+
requests: [],
|
|
563
|
+
warnings: [],
|
|
564
|
+
unknownEvents: [],
|
|
565
|
+
attachedReferenceKeys: []
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function startAgentTurn(thread, input, turnId = randomUUID()) {
|
|
569
|
+
const now = timestamp();
|
|
570
|
+
const turn = {
|
|
571
|
+
id: turnId,
|
|
572
|
+
status: "running",
|
|
573
|
+
createdAt: now,
|
|
574
|
+
updatedAt: now
|
|
575
|
+
};
|
|
576
|
+
thread.turns.push(turn);
|
|
577
|
+
thread.messages.push({
|
|
578
|
+
id: randomUUID(),
|
|
579
|
+
role: "user",
|
|
580
|
+
text: input.text,
|
|
581
|
+
turnId,
|
|
582
|
+
createdAt: now,
|
|
583
|
+
updatedAt: now,
|
|
584
|
+
streaming: false,
|
|
585
|
+
references: input.references,
|
|
586
|
+
attachments: input.attachments?.map(({ dataUrl: _dataUrl, ...attachment }) => attachment)
|
|
587
|
+
});
|
|
588
|
+
thread.status = "running";
|
|
589
|
+
thread.updatedAt = now;
|
|
590
|
+
return turn;
|
|
591
|
+
}
|
|
592
|
+
function findTurn(thread, turnId) {
|
|
593
|
+
const turn = thread.turns.find((item) => item.id === turnId);
|
|
594
|
+
if (!turn) throw new Error(`Unknown agent turn: ${turnId}`);
|
|
595
|
+
return turn;
|
|
596
|
+
}
|
|
597
|
+
function assistantMessage(thread, turnId, event) {
|
|
598
|
+
const itemId = event.itemId;
|
|
599
|
+
const existing = [...thread.messages].reverse().find((message) => message.turnId === turnId && message.role === "assistant" && (itemId === void 0 || message.id === itemId));
|
|
600
|
+
if (existing) return existing;
|
|
601
|
+
const now = event.createdAt ?? timestamp();
|
|
602
|
+
const message = {
|
|
603
|
+
id: itemId ?? randomUUID(),
|
|
604
|
+
role: "assistant",
|
|
605
|
+
text: "",
|
|
606
|
+
turnId,
|
|
607
|
+
createdAt: now,
|
|
608
|
+
updatedAt: now,
|
|
609
|
+
streaming: true
|
|
610
|
+
};
|
|
611
|
+
thread.messages.push(message);
|
|
612
|
+
return message;
|
|
613
|
+
}
|
|
614
|
+
function projectProviderEvent(thread, defaultTurnId, event) {
|
|
615
|
+
const turnId = event.turnId ?? defaultTurnId;
|
|
616
|
+
const now = event.createdAt ?? timestamp();
|
|
617
|
+
thread.updatedAt = now;
|
|
618
|
+
switch (event.type) {
|
|
619
|
+
case "session.started":
|
|
620
|
+
thread.providerSessionId = event.sessionId;
|
|
621
|
+
return;
|
|
622
|
+
case "thread.started":
|
|
623
|
+
thread.providerThreadId = event.threadId;
|
|
624
|
+
return;
|
|
625
|
+
case "turn.started": return;
|
|
626
|
+
case "message.started":
|
|
627
|
+
assistantMessage(thread, turnId, event);
|
|
628
|
+
return;
|
|
629
|
+
case "message.delta": {
|
|
630
|
+
const message = assistantMessage(thread, turnId, event);
|
|
631
|
+
message.text += event.text;
|
|
632
|
+
message.updatedAt = now;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
case "message.completed":
|
|
636
|
+
case "message": {
|
|
637
|
+
const message = assistantMessage(thread, turnId, event);
|
|
638
|
+
const text = event.type === "message" ? event.text : event.text;
|
|
639
|
+
if (text !== void 0 && (!message.text || text !== message.text)) message.text = text;
|
|
640
|
+
message.streaming = false;
|
|
641
|
+
message.updatedAt = now;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
case "command":
|
|
645
|
+
case "tool.started":
|
|
646
|
+
case "tool.updated":
|
|
647
|
+
case "tool.completed": {
|
|
648
|
+
const legacy = event.type === "command";
|
|
649
|
+
const id = event.itemId ?? event.id ?? `${turnId}:${legacy ? event.command : event.title}`;
|
|
650
|
+
const existing = thread.activities.find((activity) => activity.id === id);
|
|
651
|
+
const status = legacy ? event.status === "running" ? "running" : "completed" : event.status ?? (event.type === "tool.completed" ? "completed" : "running");
|
|
652
|
+
if (existing) {
|
|
653
|
+
existing.status = status;
|
|
654
|
+
existing.title = legacy ? event.command : event.title;
|
|
655
|
+
if (!legacy && event.output !== void 0) existing.output = event.output;
|
|
656
|
+
} else thread.activities.push({
|
|
657
|
+
id,
|
|
658
|
+
turnId,
|
|
659
|
+
createdAt: now,
|
|
660
|
+
tool: legacy ? "command_execution" : event.tool,
|
|
661
|
+
title: legacy ? event.command : event.title,
|
|
662
|
+
status,
|
|
663
|
+
...!legacy && event.output !== void 0 ? { output: event.output } : {}
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
case "plan.updated":
|
|
668
|
+
findTurn(thread, turnId).plan = event.markdown;
|
|
669
|
+
return;
|
|
670
|
+
case "thinking": {
|
|
671
|
+
const turn = findTurn(thread, turnId);
|
|
672
|
+
if (!turn.thinking) turn.thinking = [];
|
|
673
|
+
turn.thinking.push({
|
|
674
|
+
text: event.text,
|
|
675
|
+
createdAt: now
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
case "request.opened":
|
|
680
|
+
thread.requests.push({
|
|
681
|
+
id: event.requestId,
|
|
682
|
+
turnId,
|
|
683
|
+
kind: event.requestKind,
|
|
684
|
+
title: event.title,
|
|
685
|
+
details: event.details,
|
|
686
|
+
choices: event.choices,
|
|
687
|
+
allowCustom: event.allowCustom,
|
|
688
|
+
status: "pending"
|
|
689
|
+
});
|
|
690
|
+
return;
|
|
691
|
+
case "request.resolved": {
|
|
692
|
+
const request = thread.requests.find((item) => item.id === event.requestId);
|
|
693
|
+
if (request) {
|
|
694
|
+
request.status = "resolved";
|
|
695
|
+
request.decision = event.decision;
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
case "warning":
|
|
700
|
+
thread.warnings.push({
|
|
701
|
+
message: event.message,
|
|
702
|
+
turnId: event.turnId
|
|
703
|
+
});
|
|
704
|
+
return;
|
|
705
|
+
case "error": {
|
|
706
|
+
thread.status = "error";
|
|
707
|
+
const turn = findTurn(thread, turnId);
|
|
708
|
+
turn.status = "failed";
|
|
709
|
+
turn.updatedAt = now;
|
|
710
|
+
thread.warnings.push({
|
|
711
|
+
message: event.message,
|
|
712
|
+
turnId: event.turnId ?? turnId
|
|
713
|
+
});
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
case "turn.interrupted": {
|
|
717
|
+
const turn = findTurn(thread, turnId);
|
|
718
|
+
turn.status = "interrupted";
|
|
719
|
+
turn.updatedAt = now;
|
|
720
|
+
thread.status = "idle";
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
case "turn.completed":
|
|
724
|
+
case "done": {
|
|
725
|
+
const turn = findTurn(thread, turnId);
|
|
726
|
+
turn.status = "completed";
|
|
727
|
+
turn.updatedAt = now;
|
|
728
|
+
if (event.type === "turn.completed" && event.usage) turn.usage = {
|
|
729
|
+
...turn.usage,
|
|
730
|
+
...event.usage
|
|
731
|
+
};
|
|
732
|
+
for (const message of thread.messages) if (message.turnId === turnId) message.streaming = false;
|
|
733
|
+
thread.status = "idle";
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
case "context.updated": {
|
|
737
|
+
const turn = findTurn(thread, turnId);
|
|
738
|
+
turn.usage = event.usage;
|
|
739
|
+
turn.updatedAt = now;
|
|
740
|
+
thread.updatedAt = now;
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
case "unknown":
|
|
744
|
+
thread.unknownEvents.push(event);
|
|
745
|
+
if (thread.unknownEvents.length > 500) thread.unknownEvents.splice(0, thread.unknownEvents.length - 500);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/agent-state.ts
|
|
751
|
+
const agentWarningSchema = z.object({
|
|
752
|
+
message: z.string(),
|
|
753
|
+
turnId: z.string().optional()
|
|
754
|
+
});
|
|
755
|
+
const SAVE_DEBOUNCE_MS = 100;
|
|
756
|
+
var AgentState = class {
|
|
757
|
+
#database;
|
|
758
|
+
#saveStatement;
|
|
759
|
+
#deleteStatement;
|
|
760
|
+
#pending = /* @__PURE__ */ new Map();
|
|
761
|
+
#timers = /* @__PURE__ */ new Map();
|
|
762
|
+
constructor(file) {
|
|
763
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
764
|
+
this.#database = new DatabaseSync(file);
|
|
765
|
+
this.#database.exec("CREATE TABLE IF NOT EXISTS agent_threads (id TEXT PRIMARY KEY, data TEXT NOT NULL)");
|
|
766
|
+
this.#saveStatement = this.#database.prepare("INSERT INTO agent_threads (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data");
|
|
767
|
+
this.#deleteStatement = this.#database.prepare("DELETE FROM agent_threads WHERE id = ?");
|
|
768
|
+
}
|
|
769
|
+
load() {
|
|
770
|
+
return this.#database.prepare("SELECT data FROM agent_threads").all().map((row) => {
|
|
771
|
+
if (typeof row.data !== "string") throw new Error("Invalid persisted agent thread");
|
|
772
|
+
const thread = JSON.parse(row.data);
|
|
773
|
+
return {
|
|
774
|
+
...thread,
|
|
775
|
+
warnings: thread.warnings.map((w) => agentWarningSchema.safeParse(w)).filter((r) => r.success).map((r) => r.data)
|
|
776
|
+
};
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
save(thread) {
|
|
780
|
+
this.#pending.set(thread.id, thread);
|
|
781
|
+
const existing = this.#timers.get(thread.id);
|
|
782
|
+
if (existing) clearTimeout(existing);
|
|
783
|
+
const timer = setTimeout(() => {
|
|
784
|
+
this.#timers.delete(thread.id);
|
|
785
|
+
this.#flushOne(thread.id);
|
|
786
|
+
}, SAVE_DEBOUNCE_MS);
|
|
787
|
+
timer.unref?.();
|
|
788
|
+
this.#timers.set(thread.id, timer);
|
|
789
|
+
}
|
|
790
|
+
#flushOne(threadId) {
|
|
791
|
+
const thread = this.#pending.get(threadId);
|
|
792
|
+
if (!thread) return;
|
|
793
|
+
this.#pending.delete(threadId);
|
|
794
|
+
this.#saveStatement.run(thread.id, JSON.stringify(thread));
|
|
795
|
+
}
|
|
796
|
+
delete(threadId) {
|
|
797
|
+
const timer = this.#timers.get(threadId);
|
|
798
|
+
if (timer) {
|
|
799
|
+
clearTimeout(timer);
|
|
800
|
+
this.#timers.delete(threadId);
|
|
801
|
+
}
|
|
802
|
+
this.#pending.delete(threadId);
|
|
803
|
+
this.#deleteStatement.run(threadId);
|
|
804
|
+
}
|
|
805
|
+
close() {
|
|
806
|
+
for (const timer of this.#timers.values()) clearTimeout(timer);
|
|
807
|
+
this.#timers.clear();
|
|
808
|
+
for (const threadId of Array.from(this.#pending.keys())) this.#flushOne(threadId);
|
|
809
|
+
this.#database.close();
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/agent-manager.ts
|
|
814
|
+
var AgentManager = class {
|
|
815
|
+
#provider;
|
|
816
|
+
#cwd;
|
|
817
|
+
#skillsDir;
|
|
818
|
+
#threads = /* @__PURE__ */ new Map();
|
|
819
|
+
#state;
|
|
820
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
821
|
+
#controllers = /* @__PURE__ */ new Map();
|
|
822
|
+
#activeTurns = /* @__PURE__ */ new Set();
|
|
823
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
824
|
+
#closed = false;
|
|
825
|
+
constructor(input) {
|
|
826
|
+
this.#provider = input.provider;
|
|
827
|
+
this.#cwd = input.cwd;
|
|
828
|
+
this.#skillsDir = input.skillsDir;
|
|
829
|
+
this.#state = input.statePath ? new AgentState(input.statePath) : void 0;
|
|
830
|
+
for (const thread of this.#state?.load() ?? []) {
|
|
831
|
+
const recovered = recoverThread(thread);
|
|
832
|
+
this.#threads.set(thread.id, thread);
|
|
833
|
+
if (recovered) this.#state?.save(thread);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
metadata() {
|
|
837
|
+
return {
|
|
838
|
+
name: this.#provider.name,
|
|
839
|
+
label: this.#provider.label ?? this.#provider.name,
|
|
840
|
+
capabilities: this.#provider.capabilities ?? [],
|
|
841
|
+
modes: this.#provider.modes ?? [{
|
|
842
|
+
id: "default",
|
|
843
|
+
label: "Build"
|
|
844
|
+
}]
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
listThreads() {
|
|
848
|
+
return [...this.#threads.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
849
|
+
}
|
|
850
|
+
getThread(id) {
|
|
851
|
+
return this.#threads.get(id);
|
|
852
|
+
}
|
|
853
|
+
subscribe(listener) {
|
|
854
|
+
this.#listeners.add(listener);
|
|
855
|
+
return () => this.#listeners.delete(listener);
|
|
856
|
+
}
|
|
857
|
+
async createThread(settings) {
|
|
858
|
+
const thread = createAgentThread(this.#provider.name);
|
|
859
|
+
thread.model = settings.model;
|
|
860
|
+
thread.reasoningEffort = settings.reasoningEffort;
|
|
861
|
+
thread.options = settings.options;
|
|
862
|
+
thread.mode = settings.mode;
|
|
863
|
+
this.#threads.set(thread.id, thread);
|
|
864
|
+
this.#notify(thread.id);
|
|
865
|
+
return thread;
|
|
866
|
+
}
|
|
867
|
+
async deleteThread(id) {
|
|
868
|
+
const thread = this.#requireThread(id);
|
|
869
|
+
const session = this.#sessions.get(id);
|
|
870
|
+
this.#controllers.get(id)?.abort();
|
|
871
|
+
this.#controllers.delete(id);
|
|
872
|
+
const waiting = [...this.#activeTurns];
|
|
873
|
+
if (waiting.length > 0) await Promise.allSettled(waiting);
|
|
874
|
+
if (session) await this.#provider.closeSession?.(session);
|
|
875
|
+
this.#sessions.delete(id);
|
|
876
|
+
this.#threads.delete(thread.id);
|
|
877
|
+
this.#notify(thread.id);
|
|
878
|
+
}
|
|
879
|
+
async *sendTurn(input) {
|
|
880
|
+
const thread = this.#requireThread(input.threadId);
|
|
881
|
+
if (thread.status === "running" || this.#controllers.has(thread.id)) throw new Error("This agent thread is already running");
|
|
882
|
+
thread.model = input.model ?? thread.model;
|
|
883
|
+
thread.reasoningEffort = input.reasoningEffort ?? thread.reasoningEffort;
|
|
884
|
+
thread.options = input.options ?? thread.options;
|
|
885
|
+
thread.mode = input.mode ?? thread.mode;
|
|
886
|
+
const references = prepareTurnReferences(input.references, thread.attachedReferenceKeys);
|
|
887
|
+
const turn = startAgentTurn(thread, {
|
|
888
|
+
text: input.prompt,
|
|
889
|
+
references: input.references.map(displayReference),
|
|
890
|
+
attachments: input.attachments
|
|
891
|
+
});
|
|
892
|
+
this.#notify(thread.id);
|
|
893
|
+
const controller = new AbortController();
|
|
894
|
+
this.#controllers.set(thread.id, controller);
|
|
895
|
+
try {
|
|
896
|
+
let session = this.#sessions.get(thread.id);
|
|
897
|
+
if (!session) {
|
|
898
|
+
session = this.#provider.startSession ? await this.#provider.startSession({
|
|
899
|
+
cwd: this.#cwd,
|
|
900
|
+
providerThreadId: thread.providerThreadId,
|
|
901
|
+
model: thread.model,
|
|
902
|
+
reasoningEffort: thread.reasoningEffort,
|
|
903
|
+
options: thread.options,
|
|
904
|
+
mode: thread.mode
|
|
905
|
+
}) : {
|
|
906
|
+
id: thread.id,
|
|
907
|
+
providerThreadId: thread.providerThreadId
|
|
908
|
+
};
|
|
909
|
+
this.#sessions.set(thread.id, session);
|
|
910
|
+
}
|
|
911
|
+
const providerInput = {
|
|
912
|
+
prompt: input.prompt,
|
|
913
|
+
cwd: this.#cwd,
|
|
914
|
+
skillsDir: this.#skillsDir,
|
|
915
|
+
references,
|
|
916
|
+
attachments: input.attachments,
|
|
917
|
+
model: thread.model,
|
|
918
|
+
reasoningEffort: thread.reasoningEffort,
|
|
919
|
+
options: thread.options,
|
|
920
|
+
mode: thread.mode,
|
|
921
|
+
signal: controller.signal
|
|
922
|
+
};
|
|
923
|
+
const events = this.#provider.sendTurn ? this.#provider.sendTurn(session, providerInput) : this.#provider.prompt?.(providerInput);
|
|
924
|
+
if (!events) throw new Error(`Provider ${this.#provider.name} cannot send turns`);
|
|
925
|
+
let completed = false;
|
|
926
|
+
for await (const event of events) {
|
|
927
|
+
projectProviderEvent(thread, turn.id, event);
|
|
928
|
+
this.#notify(thread.id);
|
|
929
|
+
if (event.type === "thread.started") {
|
|
930
|
+
session = {
|
|
931
|
+
...session,
|
|
932
|
+
providerThreadId: event.threadId
|
|
933
|
+
};
|
|
934
|
+
this.#sessions.set(thread.id, session);
|
|
935
|
+
}
|
|
936
|
+
if (event.type === "turn.completed" || event.type === "done") completed = true;
|
|
937
|
+
yield {
|
|
938
|
+
...event,
|
|
939
|
+
turnId: event.turnId ?? turn.id
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
if (!completed) {
|
|
943
|
+
const event = controller.signal.aborted ? {
|
|
944
|
+
type: "turn.interrupted",
|
|
945
|
+
turnId: turn.id
|
|
946
|
+
} : {
|
|
947
|
+
type: "turn.completed",
|
|
948
|
+
turnId: turn.id
|
|
949
|
+
};
|
|
950
|
+
projectProviderEvent(thread, turn.id, event);
|
|
951
|
+
this.#notify(thread.id);
|
|
952
|
+
yield event;
|
|
953
|
+
}
|
|
954
|
+
} catch (error) {
|
|
955
|
+
const event = {
|
|
956
|
+
type: controller.signal.aborted ? "turn.interrupted" : "error",
|
|
957
|
+
turnId: turn.id,
|
|
958
|
+
...controller.signal.aborted ? {} : { message: error instanceof Error ? error.message : String(error) }
|
|
959
|
+
};
|
|
960
|
+
projectProviderEvent(thread, turn.id, event);
|
|
961
|
+
this.#notify(thread.id);
|
|
962
|
+
yield event;
|
|
963
|
+
} finally {
|
|
964
|
+
this.#controllers.delete(thread.id);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
async interrupt(threadId) {
|
|
968
|
+
const thread = this.#threads.get(threadId);
|
|
969
|
+
const turn = thread?.turns.findLast((item) => item.status === "running");
|
|
970
|
+
if (thread?.status === "running" && turn) {
|
|
971
|
+
projectProviderEvent(thread, turn.id, {
|
|
972
|
+
type: "turn.interrupted",
|
|
973
|
+
turnId: turn.id
|
|
974
|
+
});
|
|
975
|
+
this.#notify(thread.id);
|
|
976
|
+
}
|
|
977
|
+
const session = this.#sessions.get(threadId);
|
|
978
|
+
this.#controllers.get(threadId)?.abort();
|
|
979
|
+
if (session) await this.#provider.interrupt?.(session);
|
|
980
|
+
}
|
|
981
|
+
async respondToRequest(threadId, requestId, decision) {
|
|
982
|
+
const thread = this.#requireThread(threadId);
|
|
983
|
+
const session = this.#sessions.get(threadId);
|
|
984
|
+
if (!session || !this.#provider.respondToRequest) throw new Error("This provider cannot respond to interactive requests");
|
|
985
|
+
await this.#provider.respondToRequest(session, requestId, decision);
|
|
986
|
+
const request = thread.requests.find((item) => item.id === requestId);
|
|
987
|
+
if (request) projectProviderEvent(thread, request.turnId, {
|
|
988
|
+
type: "request.resolved",
|
|
989
|
+
requestId,
|
|
990
|
+
decision
|
|
991
|
+
});
|
|
992
|
+
this.#notify(thread.id);
|
|
993
|
+
}
|
|
994
|
+
async close() {
|
|
995
|
+
if (this.#closed) return;
|
|
996
|
+
for (const threadId of this.#controllers.keys()) await this.interrupt(threadId);
|
|
997
|
+
if (this.#activeTurns.size > 0) await Promise.allSettled(this.#activeTurns);
|
|
998
|
+
for (const session of this.#sessions.values()) await this.#provider.closeSession?.(session);
|
|
999
|
+
this.#closed = true;
|
|
1000
|
+
this.#state?.close();
|
|
1001
|
+
}
|
|
1002
|
+
trackActiveTurn(promise) {
|
|
1003
|
+
const settled = promise.then(() => void 0, () => void 0);
|
|
1004
|
+
this.#activeTurns.add(settled);
|
|
1005
|
+
settled.finally(() => {
|
|
1006
|
+
this.#activeTurns.delete(settled);
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
#requireThread(id) {
|
|
1010
|
+
const thread = this.#threads.get(id);
|
|
1011
|
+
if (!thread) throw new Error(`Unknown agent thread: ${id}`);
|
|
1012
|
+
return thread;
|
|
1013
|
+
}
|
|
1014
|
+
#notify(threadId) {
|
|
1015
|
+
const thread = this.#threads.get(threadId);
|
|
1016
|
+
if (!this.#closed) if (thread) this.#state?.save(thread);
|
|
1017
|
+
else this.#state?.delete(threadId);
|
|
1018
|
+
for (const listener of this.#listeners) listener(threadId, thread);
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
function recoverThread(thread) {
|
|
1022
|
+
let recovered = thread.status === "running";
|
|
1023
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1024
|
+
for (const turn of thread.turns) {
|
|
1025
|
+
if (turn.status !== "running") continue;
|
|
1026
|
+
turn.status = "interrupted";
|
|
1027
|
+
turn.updatedAt = now;
|
|
1028
|
+
recovered = true;
|
|
1029
|
+
for (const message of thread.messages) if (message.turnId === turn.id) message.streaming = false;
|
|
1030
|
+
}
|
|
1031
|
+
if (recovered) {
|
|
1032
|
+
thread.status = "idle";
|
|
1033
|
+
thread.updatedAt = now;
|
|
1034
|
+
}
|
|
1035
|
+
return recovered;
|
|
1036
|
+
}
|
|
1037
|
+
//#endregion
|
|
1038
|
+
//#region src/agent-input.ts
|
|
1039
|
+
const MAX_ATTACHMENTS = 8;
|
|
1040
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
1041
|
+
const MAX_DATA_URL_CHARACTERS = 14e6;
|
|
1042
|
+
function validateAgentAttachments(value) {
|
|
1043
|
+
if (value === void 0) return [];
|
|
1044
|
+
if (!Array.isArray(value) || value.length > MAX_ATTACHMENTS) throw new Error("Invalid agent attachments");
|
|
1045
|
+
const attachments = [];
|
|
1046
|
+
for (const item of value) {
|
|
1047
|
+
if (typeof item !== "object" || item === null) throw new Error("Invalid agent image attachment");
|
|
1048
|
+
if (!("type" in item) || !("id" in item) || !("name" in item) || !("mimeType" in item) || !("sizeBytes" in item) || !("dataUrl" in item)) throw new Error("Invalid agent image attachment");
|
|
1049
|
+
if (item.type !== "image" || typeof item.id !== "string" || !item.id || typeof item.name !== "string" || !item.name || typeof item.mimeType !== "string" || !item.mimeType.startsWith("image/") || typeof item.sizeBytes !== "number" || !Number.isInteger(item.sizeBytes) || item.sizeBytes < 0 || item.sizeBytes > MAX_IMAGE_BYTES || typeof item.dataUrl !== "string" || item.dataUrl.length > MAX_DATA_URL_CHARACTERS || !item.dataUrl.startsWith(`data:${item.mimeType};base64,`) || Buffer.byteLength(item.dataUrl.slice(item.dataUrl.indexOf(",") + 1), "base64") !== item.sizeBytes) throw new Error("Invalid agent image attachment");
|
|
1050
|
+
attachments.push({
|
|
1051
|
+
type: item.type,
|
|
1052
|
+
id: item.id,
|
|
1053
|
+
name: item.name,
|
|
1054
|
+
mimeType: item.mimeType,
|
|
1055
|
+
sizeBytes: item.sizeBytes,
|
|
1056
|
+
dataUrl: item.dataUrl
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
return attachments;
|
|
1060
|
+
}
|
|
1061
|
+
//#endregion
|
|
1062
|
+
//#region src/server.ts
|
|
1063
|
+
async function loadConfig(projectDir, configFile = "nawc.config.ts") {
|
|
1064
|
+
const file = path.resolve(projectDir, configFile);
|
|
1065
|
+
return createJiti(import.meta.url, { interopDefault: true }).import(file, { default: true });
|
|
1066
|
+
}
|
|
1067
|
+
function message(error) {
|
|
1068
|
+
return error instanceof Error ? error.message : String(error);
|
|
1069
|
+
}
|
|
1070
|
+
function rawDataText(data) {
|
|
1071
|
+
if (Array.isArray(data)) return Buffer.concat(data).toString();
|
|
1072
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString();
|
|
1073
|
+
return data.toString();
|
|
1074
|
+
}
|
|
1075
|
+
async function listPromptSkills(config, skillsDir, providerSkills) {
|
|
1076
|
+
const skills = providerSkills.filter((skill) => skill.enabled !== false).map((skill) => ({
|
|
1077
|
+
name: skill.name,
|
|
1078
|
+
path: skill.path,
|
|
1079
|
+
source: skill.scope ? `${config.provider.name} · ${skill.scope}` : config.provider.name,
|
|
1080
|
+
...skill.displayName ? { displayName: skill.displayName } : {},
|
|
1081
|
+
...skill.shortDescription ? { shortDescription: skill.shortDescription } : {},
|
|
1082
|
+
...skill.description ? { description: skill.description } : {},
|
|
1083
|
+
...skill.scope ? { scope: skill.scope } : {}
|
|
1084
|
+
}));
|
|
1085
|
+
for (const plugin of config.plugins) for (const skill of plugin.skills ?? []) skills.push({
|
|
1086
|
+
name: skill.name,
|
|
1087
|
+
path: await safeExistingPath(skillsDir, path.join(skill.name, "SKILL.md")),
|
|
1088
|
+
source: plugin.name,
|
|
1089
|
+
displayName: skill.name,
|
|
1090
|
+
shortDescription: "NAWC plugin skill"
|
|
1091
|
+
});
|
|
1092
|
+
return skills;
|
|
1093
|
+
}
|
|
1094
|
+
async function createNawcServer(options) {
|
|
1095
|
+
const projectDir = path.resolve(options.projectDir);
|
|
1096
|
+
const config = await loadConfig(projectDir, options.configFile);
|
|
1097
|
+
const baseDir = path.resolve(projectDir, config.baseDir);
|
|
1098
|
+
const srcDir = path.join(projectDir, "src");
|
|
1099
|
+
const noteSearch = new NoteSearchIndex(srcDir);
|
|
1100
|
+
await assertGitRepository(baseDir);
|
|
1101
|
+
const skillsDir = await syncSkills(projectDir, config.plugins);
|
|
1102
|
+
const agentManager = new AgentManager({
|
|
1103
|
+
provider: config.provider,
|
|
1104
|
+
cwd: baseDir,
|
|
1105
|
+
skillsDir,
|
|
1106
|
+
statePath: path.join(projectDir, ".nawc", "state.sqlite")
|
|
1107
|
+
});
|
|
1108
|
+
let providerSkillsPromise;
|
|
1109
|
+
let providerModelsPromise;
|
|
1110
|
+
let providerSettingsPromise;
|
|
1111
|
+
const getProviderSkills = () => {
|
|
1112
|
+
if (!providerSkillsPromise) providerSkillsPromise = (config.provider.listSkills ? config.provider.listSkills({
|
|
1113
|
+
cwd: baseDir,
|
|
1114
|
+
skillsDir
|
|
1115
|
+
}) : Promise.resolve([])).catch((error) => {
|
|
1116
|
+
providerSkillsPromise = void 0;
|
|
1117
|
+
throw error;
|
|
1118
|
+
});
|
|
1119
|
+
return providerSkillsPromise;
|
|
1120
|
+
};
|
|
1121
|
+
const getPromptSkills = async () => listPromptSkills(config, skillsDir, await getProviderSkills());
|
|
1122
|
+
const getProviderModels = () => {
|
|
1123
|
+
if (!providerModelsPromise) providerModelsPromise = (config.provider.listModels ? config.provider.listModels({ cwd: baseDir }) : Promise.resolve([])).catch((error) => {
|
|
1124
|
+
providerModelsPromise = void 0;
|
|
1125
|
+
throw error;
|
|
1126
|
+
});
|
|
1127
|
+
return providerModelsPromise;
|
|
1128
|
+
};
|
|
1129
|
+
const getProviderSettings = () => {
|
|
1130
|
+
if (!providerSettingsPromise) providerSettingsPromise = (config.provider.getSettings ? config.provider.getSettings({ cwd: baseDir }) : Promise.resolve({})).catch((error) => {
|
|
1131
|
+
providerSettingsPromise = void 0;
|
|
1132
|
+
throw error;
|
|
1133
|
+
});
|
|
1134
|
+
return providerSettingsPromise;
|
|
1135
|
+
};
|
|
1136
|
+
const validateAgentSelection = async (selection) => {
|
|
1137
|
+
const models = await getProviderModels();
|
|
1138
|
+
const settings = await getProviderSettings();
|
|
1139
|
+
const modelId = selection.model ?? settings.model;
|
|
1140
|
+
const model = modelId ? models.find((item) => item.id === modelId) : void 0;
|
|
1141
|
+
if (selection.model && !model) throw new Error(`Unknown model: ${selection.model}`);
|
|
1142
|
+
if (selection.reasoningEffort && model?.reasoningEfforts && !model.reasoningEfforts.some((effort) => effort.id === selection.reasoningEffort)) throw new Error(`Unknown reasoning effort: ${selection.reasoningEffort}`);
|
|
1143
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1144
|
+
for (const option of selection.options ?? []) {
|
|
1145
|
+
if (seen.has(option.id)) throw new Error(`Duplicate provider option: ${option.id}`);
|
|
1146
|
+
seen.add(option.id);
|
|
1147
|
+
const descriptor = model?.options?.find((item) => item.id === option.id);
|
|
1148
|
+
if (!descriptor) throw new Error(`Unknown provider option: ${option.id}`);
|
|
1149
|
+
if (descriptor.type === "boolean" && typeof option.value !== "boolean" || descriptor.type === "select" && (typeof option.value !== "string" || !descriptor.choices.some((choice) => choice.id === option.value))) throw new Error(`Invalid value for provider option: ${option.id}`);
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
const fileListeners = /* @__PURE__ */ new Set();
|
|
1153
|
+
const watcher = watch(srcDir, {
|
|
1154
|
+
ignored: [
|
|
1155
|
+
"**/.git/**",
|
|
1156
|
+
"**/node_modules/**",
|
|
1157
|
+
"**/.skills/**"
|
|
1158
|
+
],
|
|
1159
|
+
ignoreInitial: true,
|
|
1160
|
+
usePolling: true,
|
|
1161
|
+
interval: 500
|
|
1162
|
+
});
|
|
1163
|
+
watcher.on("all", (event, file) => {
|
|
1164
|
+
noteSearch.invalidate();
|
|
1165
|
+
for (const listener of fileListeners) listener(event, file);
|
|
1166
|
+
});
|
|
1167
|
+
const watchedSourcePaths = [];
|
|
1168
|
+
const MAX_WATCHED_SOURCE_PATHS = 1e3;
|
|
1169
|
+
function watchSourcePath(absolutePath) {
|
|
1170
|
+
if (watchedSourcePaths.includes(absolutePath)) {
|
|
1171
|
+
const idx = watchedSourcePaths.indexOf(absolutePath);
|
|
1172
|
+
watchedSourcePaths.splice(idx, 1);
|
|
1173
|
+
watchedSourcePaths.push(absolutePath);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (watchedSourcePaths.length >= MAX_WATCHED_SOURCE_PATHS) {
|
|
1177
|
+
const evicted = watchedSourcePaths.shift();
|
|
1178
|
+
if (evicted) watcher.unwatch(evicted);
|
|
1179
|
+
}
|
|
1180
|
+
watchedSourcePaths.push(absolutePath);
|
|
1181
|
+
watcher.add(absolutePath);
|
|
1182
|
+
}
|
|
1183
|
+
const app = new Hono();
|
|
1184
|
+
app.onError((error, context) => context.json({ error: message(error) }, 400));
|
|
1185
|
+
app.get("/api/meta", (context) => context.json({
|
|
1186
|
+
provider: config.provider.name,
|
|
1187
|
+
baseDir,
|
|
1188
|
+
srcDir,
|
|
1189
|
+
editor: {
|
|
1190
|
+
name: (config.editor ?? vscode()).name,
|
|
1191
|
+
label: (config.editor ?? vscode()).label,
|
|
1192
|
+
icon: (config.editor ?? vscode()).icon
|
|
1193
|
+
},
|
|
1194
|
+
theme: config.theme ?? nawcLight(),
|
|
1195
|
+
plugins: config.plugins.map(({ name, nodes }) => ({
|
|
1196
|
+
name,
|
|
1197
|
+
nodes
|
|
1198
|
+
}))
|
|
1199
|
+
}));
|
|
1200
|
+
app.get("/api/notes", async (context) => context.json(await listNotes(srcDir)));
|
|
1201
|
+
app.get("/api/search", async (context) => {
|
|
1202
|
+
const query = context.req.query("q") ?? "";
|
|
1203
|
+
return context.json(await noteSearch.search(query));
|
|
1204
|
+
});
|
|
1205
|
+
app.get("/api/files", async (context) => context.json(await listEntries(srcDir)));
|
|
1206
|
+
app.get("/api/prompt/skills", async (context) => context.json((await getPromptSkills()).map(({ name, source, displayName, shortDescription, description, scope }) => ({
|
|
1207
|
+
name,
|
|
1208
|
+
source,
|
|
1209
|
+
...displayName ? { displayName } : {},
|
|
1210
|
+
...shortDescription ? { shortDescription } : {},
|
|
1211
|
+
...description ? { description } : {},
|
|
1212
|
+
...scope ? { scope } : {}
|
|
1213
|
+
}))));
|
|
1214
|
+
app.get("/api/prompt/files", async (context) => {
|
|
1215
|
+
const query = (context.req.query("q") ?? "").trim().toLowerCase();
|
|
1216
|
+
return context.json(await listProjectPaths(baseDir, {
|
|
1217
|
+
query,
|
|
1218
|
+
limit: 50
|
|
1219
|
+
}));
|
|
1220
|
+
});
|
|
1221
|
+
app.get("/api/prompt/models", async (context) => context.json(await getProviderModels()));
|
|
1222
|
+
app.get("/api/prompt/settings", async (context) => context.json(await getProviderSettings()));
|
|
1223
|
+
app.get("/api/prompt/commands", async (context) => context.json(config.provider.listCommands ? await config.provider.listCommands({ cwd: baseDir }) : config.provider.slashCommands ?? []));
|
|
1224
|
+
app.get("/api/agent/provider", (context) => context.json(agentManager.metadata()));
|
|
1225
|
+
app.get("/api/agent/threads", (context) => context.json(agentManager.listThreads()));
|
|
1226
|
+
app.get("/api/agent/threads/:id", (context) => {
|
|
1227
|
+
const thread = agentManager.getThread(context.req.param("id"));
|
|
1228
|
+
if (!thread) throw new Error("Unknown agent thread");
|
|
1229
|
+
return context.json(thread);
|
|
1230
|
+
});
|
|
1231
|
+
app.post("/api/agent/threads", async (context) => {
|
|
1232
|
+
const body = await context.req.json();
|
|
1233
|
+
await validateAgentSelection(body);
|
|
1234
|
+
return context.json(await agentManager.createThread(body));
|
|
1235
|
+
});
|
|
1236
|
+
app.delete("/api/agent/threads/:id", async (context) => {
|
|
1237
|
+
await agentManager.deleteThread(context.req.param("id"));
|
|
1238
|
+
return context.json({ ok: true });
|
|
1239
|
+
});
|
|
1240
|
+
app.post("/api/agent/threads/:id/interrupt", async (context) => {
|
|
1241
|
+
await agentManager.interrupt(context.req.param("id"));
|
|
1242
|
+
return context.json({ ok: true });
|
|
1243
|
+
});
|
|
1244
|
+
app.post("/api/agent/threads/:id/requests/:requestId", async (context) => {
|
|
1245
|
+
const body = await context.req.json();
|
|
1246
|
+
if (typeof body.decision !== "string" || !body.decision) throw new Error("Invalid decision");
|
|
1247
|
+
await agentManager.respondToRequest(context.req.param("id"), context.req.param("requestId"), body.decision);
|
|
1248
|
+
return context.json({ ok: true });
|
|
1249
|
+
});
|
|
1250
|
+
app.post("/api/agent/threads/:id/turns", async (context) => {
|
|
1251
|
+
const body = await context.req.json();
|
|
1252
|
+
if (typeof body.prompt !== "string" || !body.prompt.trim()) throw new Error("Prompt is required");
|
|
1253
|
+
if ((body.references?.length ?? 0) > 50) throw new Error("Too many agent references");
|
|
1254
|
+
const attachments = validateAgentAttachments(body.attachments);
|
|
1255
|
+
await validateAgentSelection(body);
|
|
1256
|
+
const availableSkills = new Map((await getPromptSkills()).map((skill) => [skill.name, skill.path]));
|
|
1257
|
+
const references = [];
|
|
1258
|
+
if (body.note) {
|
|
1259
|
+
const content = body.noteContent ?? await readNote(srcDir, body.note);
|
|
1260
|
+
references.push({
|
|
1261
|
+
type: "note",
|
|
1262
|
+
path: body.note,
|
|
1263
|
+
content
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
for (const reference of body.references ?? []) if (reference.type === "file") {
|
|
1267
|
+
if (!await isProjectPath(baseDir, reference.path)) continue;
|
|
1268
|
+
references.push(reference);
|
|
1269
|
+
} else if (reference.type === "skill") {
|
|
1270
|
+
const skillPath = availableSkills.get(reference.name);
|
|
1271
|
+
if (!skillPath) throw new Error(`Unknown skill reference: ${reference.name}`);
|
|
1272
|
+
references.push({
|
|
1273
|
+
...reference,
|
|
1274
|
+
path: skillPath
|
|
1275
|
+
});
|
|
1276
|
+
} else if (reference.type === "diagnostic" && reference.message) references.push(reference);
|
|
1277
|
+
else throw new Error("Invalid agent reference");
|
|
1278
|
+
const threadId = context.req.param("id");
|
|
1279
|
+
return streamSSE(context, async (stream) => {
|
|
1280
|
+
stream.onAbort(() => {
|
|
1281
|
+
agentManager.interrupt(threadId);
|
|
1282
|
+
});
|
|
1283
|
+
const turn = agentManager.sendTurn({
|
|
1284
|
+
threadId,
|
|
1285
|
+
prompt: body.prompt,
|
|
1286
|
+
references,
|
|
1287
|
+
attachments,
|
|
1288
|
+
model: body.model,
|
|
1289
|
+
reasoningEffort: body.reasoningEffort,
|
|
1290
|
+
options: body.options,
|
|
1291
|
+
mode: body.mode
|
|
1292
|
+
});
|
|
1293
|
+
let drainError;
|
|
1294
|
+
const drain = (async () => {
|
|
1295
|
+
try {
|
|
1296
|
+
for await (const event of turn) await stream.writeSSE({
|
|
1297
|
+
data: JSON.stringify(event),
|
|
1298
|
+
event: event.type
|
|
1299
|
+
});
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
drainError = error;
|
|
1302
|
+
}
|
|
1303
|
+
})();
|
|
1304
|
+
agentManager.trackActiveTurn(drain);
|
|
1305
|
+
await drain;
|
|
1306
|
+
if (drainError) throw drainError;
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
app.get("/api/events", (context) => streamSSE(context, async (stream) => {
|
|
1310
|
+
const agentListener = (threadId, thread) => void stream.writeSSE({
|
|
1311
|
+
data: JSON.stringify({
|
|
1312
|
+
event: "agent",
|
|
1313
|
+
threadId,
|
|
1314
|
+
thread: thread ?? null
|
|
1315
|
+
}),
|
|
1316
|
+
event: "agent"
|
|
1317
|
+
});
|
|
1318
|
+
const unsubscribeAgent = agentManager.subscribe(agentListener);
|
|
1319
|
+
await stream.writeSSE({ data: JSON.stringify({ event: "ready" }) });
|
|
1320
|
+
await new Promise((resolve) => {
|
|
1321
|
+
const listener = (event, file) => void stream.writeSSE({ data: JSON.stringify({
|
|
1322
|
+
event,
|
|
1323
|
+
file: path.relative(srcDir, file)
|
|
1324
|
+
}) });
|
|
1325
|
+
fileListeners.add(listener);
|
|
1326
|
+
stream.onAbort(() => {
|
|
1327
|
+
fileListeners.delete(listener);
|
|
1328
|
+
unsubscribeAgent();
|
|
1329
|
+
resolve();
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
}));
|
|
1333
|
+
app.get("/api/note", async (context) => context.text(await readNote(srcDir, context.req.query("path") ?? "")));
|
|
1334
|
+
app.put("/api/note", async (context) => {
|
|
1335
|
+
const body = await context.req.json();
|
|
1336
|
+
await writeNote(srcDir, body.path, body.content);
|
|
1337
|
+
noteSearch.invalidate();
|
|
1338
|
+
return context.json({ ok: true });
|
|
1339
|
+
});
|
|
1340
|
+
app.delete("/api/note", async (context) => {
|
|
1341
|
+
await deleteNote(srcDir, context.req.query("path") ?? "");
|
|
1342
|
+
noteSearch.invalidate();
|
|
1343
|
+
return context.json({ ok: true });
|
|
1344
|
+
});
|
|
1345
|
+
app.post("/api/note/rename", async (context) => {
|
|
1346
|
+
const body = await context.req.json();
|
|
1347
|
+
await renameNote(srcDir, body.from, body.to);
|
|
1348
|
+
noteSearch.invalidate();
|
|
1349
|
+
return context.json({ ok: true });
|
|
1350
|
+
});
|
|
1351
|
+
app.post("/api/folder", async (context) => {
|
|
1352
|
+
const { path: folder } = await context.req.json();
|
|
1353
|
+
await createFolder(srcDir, folder);
|
|
1354
|
+
noteSearch.invalidate();
|
|
1355
|
+
return context.json({ ok: true });
|
|
1356
|
+
});
|
|
1357
|
+
app.delete("/api/entry", async (context) => {
|
|
1358
|
+
await deleteEntry(srcDir, context.req.query("path") ?? "");
|
|
1359
|
+
noteSearch.invalidate();
|
|
1360
|
+
return context.json({ ok: true });
|
|
1361
|
+
});
|
|
1362
|
+
app.post("/api/entry/rename", async (context) => {
|
|
1363
|
+
const body = await context.req.json();
|
|
1364
|
+
await renameEntry(srcDir, body.from, body.to);
|
|
1365
|
+
noteSearch.invalidate();
|
|
1366
|
+
return context.json({ ok: true });
|
|
1367
|
+
});
|
|
1368
|
+
app.post("/api/entry/move", async (context) => {
|
|
1369
|
+
const body = await context.req.json();
|
|
1370
|
+
await moveEntry(srcDir, body.from, body.to, body.replace);
|
|
1371
|
+
noteSearch.invalidate();
|
|
1372
|
+
return context.json({ ok: true });
|
|
1373
|
+
});
|
|
1374
|
+
app.post("/api/source", async (context) => {
|
|
1375
|
+
const selection = await context.req.json();
|
|
1376
|
+
watchSourcePath(await safePath(baseDir, selection.file));
|
|
1377
|
+
return context.json(await resolveSource(config, baseDir, selection));
|
|
1378
|
+
});
|
|
1379
|
+
app.post("/api/editor/open", async (context) => {
|
|
1380
|
+
const request = await context.req.json();
|
|
1381
|
+
if (request.scope !== "note" && request.scope !== "source") throw new Error("Invalid editor scope");
|
|
1382
|
+
if (request.line !== void 0 && (!Number.isInteger(request.line) || request.line < 1)) throw new Error("Invalid editor line");
|
|
1383
|
+
if (request.column !== void 0 && (!Number.isInteger(request.column) || request.column < 1)) throw new Error("Invalid editor column");
|
|
1384
|
+
let file;
|
|
1385
|
+
let reveal;
|
|
1386
|
+
try {
|
|
1387
|
+
const resolved = await safeExistingPath(request.scope === "note" ? srcDir : baseDir, request.file);
|
|
1388
|
+
if (resolved.startsWith(`${baseDir}${path.sep}`) || resolved === baseDir) {
|
|
1389
|
+
reveal = path.relative(baseDir, resolved);
|
|
1390
|
+
file = baseDir;
|
|
1391
|
+
} else file = resolved;
|
|
1392
|
+
} catch (err) {
|
|
1393
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") throw new Error(`File not found: ${request.file}`);
|
|
1394
|
+
throw err;
|
|
1395
|
+
}
|
|
1396
|
+
await launchEditor(config.editor ?? vscode(), {
|
|
1397
|
+
file,
|
|
1398
|
+
line: request.line,
|
|
1399
|
+
column: request.column,
|
|
1400
|
+
reveal
|
|
1401
|
+
});
|
|
1402
|
+
return context.json({ ok: true });
|
|
1403
|
+
});
|
|
1404
|
+
app.post("/api/prompt", async (context) => {
|
|
1405
|
+
const body = await context.req.json();
|
|
1406
|
+
if (typeof body.prompt !== "string") throw new Error("Prompt must be a string");
|
|
1407
|
+
if (body.model !== void 0 && typeof body.model !== "string") throw new Error("Model must be a string");
|
|
1408
|
+
if (body.reasoningEffort !== void 0 && typeof body.reasoningEffort !== "string") throw new Error("Reasoning effort must be a string");
|
|
1409
|
+
if (body.mode !== void 0 && body.mode !== "default" && body.mode !== "plan") throw new Error("Invalid prompt mode");
|
|
1410
|
+
if ((body.references?.length ?? 0) > 50) throw new Error("Too many prompt references");
|
|
1411
|
+
if (body.model && config.provider.listModels) {
|
|
1412
|
+
if (!(await getProviderModels()).some((model) => model.id === body.model)) throw new Error(`Unknown model: ${body.model}`);
|
|
1413
|
+
}
|
|
1414
|
+
const availableSkills = new Map((await getPromptSkills()).map((skill) => [skill.name, skill.path]));
|
|
1415
|
+
const references = [];
|
|
1416
|
+
for (const reference of body.references ?? []) if (reference.type === "file") {
|
|
1417
|
+
if (!await isProjectPath(baseDir, reference.path)) continue;
|
|
1418
|
+
references.push({
|
|
1419
|
+
type: "file",
|
|
1420
|
+
path: reference.path
|
|
1421
|
+
});
|
|
1422
|
+
} else if (reference.type === "skill") {
|
|
1423
|
+
const skillPath = availableSkills.get(reference.name);
|
|
1424
|
+
if (!skillPath) throw new Error(`Unknown skill reference: ${reference.name}`);
|
|
1425
|
+
references.push({
|
|
1426
|
+
type: "skill",
|
|
1427
|
+
name: reference.name,
|
|
1428
|
+
path: skillPath
|
|
1429
|
+
});
|
|
1430
|
+
} else throw new Error("Invalid prompt reference");
|
|
1431
|
+
const referenceContext = references.length ? `\n\nNAWC references selected by the user:\n${references.map((reference) => reference.type === "file" ? `- File: ${JSON.stringify(reference.path)} (relative to the working directory)` : reference.type === "skill" ? `- Skill: ${JSON.stringify(`$${reference.name}`)} (${JSON.stringify(reference.path)}); read this SKILL.md before acting` : reference.type === "note" ? `- Note: ${JSON.stringify(reference.path)}` : `- Diagnostic: ${reference.message}`).join("\n")}` : "";
|
|
1432
|
+
return streamSSE(context, async (stream) => {
|
|
1433
|
+
if (!config.provider.prompt) throw new Error("Provider does not support the legacy prompt API");
|
|
1434
|
+
for await (const event of config.provider.prompt({
|
|
1435
|
+
prompt: body.prompt + referenceContext,
|
|
1436
|
+
cwd: baseDir,
|
|
1437
|
+
skillsDir,
|
|
1438
|
+
references,
|
|
1439
|
+
model: body.model,
|
|
1440
|
+
reasoningEffort: body.reasoningEffort,
|
|
1441
|
+
mode: body.mode
|
|
1442
|
+
})) await stream.writeSSE({
|
|
1443
|
+
data: JSON.stringify(event),
|
|
1444
|
+
event: event.type
|
|
1445
|
+
});
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
const uiRoot = path.dirname(fileURLToPath(import.meta.resolve("@nawc/ui/package.json")));
|
|
1449
|
+
const projectRequire = createRequire(path.join(projectDir, "package.json"));
|
|
1450
|
+
const pluginImports = config.plugins.flatMap((plugin, index) => plugin.client ? [`import plugin${index} from ${JSON.stringify(projectRequire.resolve(plugin.client))};`] : []).join("\n");
|
|
1451
|
+
const pluginArray = config.plugins.flatMap((plugin, index) => plugin.client ? [`plugin${index}`] : []).join(", ");
|
|
1452
|
+
const clientSyntax = config.plugins.flatMap((plugin) => (plugin.syntax ?? []).map(({ name, aliases, highlight }) => ({
|
|
1453
|
+
name,
|
|
1454
|
+
aliases,
|
|
1455
|
+
highlight
|
|
1456
|
+
})));
|
|
1457
|
+
const vitePlugins = config.plugins.flatMap((plugin) => plugin.vite ? [plugin.vite({ baseDir })] : []);
|
|
1458
|
+
const vite = await createServer$1({
|
|
1459
|
+
root: uiRoot,
|
|
1460
|
+
appType: "spa",
|
|
1461
|
+
resolve: {
|
|
1462
|
+
alias: { "@nawcui": path.join(uiRoot, "src") },
|
|
1463
|
+
dedupe: ["react", "react-dom"]
|
|
1464
|
+
},
|
|
1465
|
+
server: {
|
|
1466
|
+
middlewareMode: true,
|
|
1467
|
+
hmr: false,
|
|
1468
|
+
ws: false,
|
|
1469
|
+
watch: {
|
|
1470
|
+
usePolling: true,
|
|
1471
|
+
interval: 500
|
|
1472
|
+
},
|
|
1473
|
+
fs: { allow: [
|
|
1474
|
+
projectDir,
|
|
1475
|
+
baseDir,
|
|
1476
|
+
uiRoot
|
|
1477
|
+
] }
|
|
1478
|
+
},
|
|
1479
|
+
plugins: [...vitePlugins, {
|
|
1480
|
+
name: "nawc-configured-plugins",
|
|
1481
|
+
resolveId(id) {
|
|
1482
|
+
return id === "virtual:nawc-plugins" ? "\0virtual:nawc-plugins" : void 0;
|
|
1483
|
+
},
|
|
1484
|
+
load(id) {
|
|
1485
|
+
return id === "\0virtual:nawc-plugins" ? `${pluginImports}\nexport const syntaxes = ${JSON.stringify(clientSyntax)};\nexport default [${pluginArray}];` : void 0;
|
|
1486
|
+
}
|
|
1487
|
+
}]
|
|
1488
|
+
});
|
|
1489
|
+
const api = getRequestListener(app.fetch);
|
|
1490
|
+
const server = createServer((request, response) => {
|
|
1491
|
+
const previewRequest = isPreviewRequest(request.headers.origin, request.headers.host);
|
|
1492
|
+
const sandboxedPreview = request.headers.origin === "null";
|
|
1493
|
+
if (previewRequest && request.url?.startsWith("/api/")) {
|
|
1494
|
+
response.statusCode = 403;
|
|
1495
|
+
response.end("NAWC APIs are unavailable to interactive previews");
|
|
1496
|
+
} else if (request.url?.startsWith("/api/")) api(request, response);
|
|
1497
|
+
else {
|
|
1498
|
+
if (sandboxedPreview) response.setHeader("Access-Control-Allow-Origin", "*");
|
|
1499
|
+
vite.middlewares(request, response, (error) => {
|
|
1500
|
+
response.statusCode = 500;
|
|
1501
|
+
response.end(message(error));
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
const runSockets = new WebSocketServer({ noServer: true });
|
|
1506
|
+
const startRun = async (webSocket, selection, size) => {
|
|
1507
|
+
let inlineFile;
|
|
1508
|
+
try {
|
|
1509
|
+
const syntax = syntaxFor(config, selection.syntax);
|
|
1510
|
+
if (!syntax?.run) throw new Error(`Syntax ${selection.syntax ?? ""} is not runnable`);
|
|
1511
|
+
let runSelection = selection;
|
|
1512
|
+
if (!selection.file) {
|
|
1513
|
+
if (!syntax.extension) throw new Error(`Syntax ${syntax.name} does not support inline runs`);
|
|
1514
|
+
const runsDir = await safePath(baseDir, ".nawc/runs");
|
|
1515
|
+
await mkdir(runsDir, { recursive: true });
|
|
1516
|
+
inlineFile = path.join(runsDir, `${randomUUID()}.${syntax.extension}`);
|
|
1517
|
+
await writeFile(inlineFile, selection.source ?? "", "utf8");
|
|
1518
|
+
runSelection = {
|
|
1519
|
+
...selection,
|
|
1520
|
+
file: path.relative(baseDir, inlineFile)
|
|
1521
|
+
};
|
|
1522
|
+
} else await safePath(baseDir, selection.file);
|
|
1523
|
+
const run = syntax.run({
|
|
1524
|
+
...runSelection,
|
|
1525
|
+
cwd: baseDir
|
|
1526
|
+
});
|
|
1527
|
+
const [command, ...args] = run.command;
|
|
1528
|
+
if (!command) throw new Error("Runnable syntax returned an empty command");
|
|
1529
|
+
const child = spawn(command, args, {
|
|
1530
|
+
cols: size.cols,
|
|
1531
|
+
cwd: run.cwd,
|
|
1532
|
+
env: {
|
|
1533
|
+
...process.env,
|
|
1534
|
+
TERM: "xterm-256color"
|
|
1535
|
+
},
|
|
1536
|
+
name: "xterm-256color",
|
|
1537
|
+
rows: size.rows
|
|
1538
|
+
});
|
|
1539
|
+
if (run.script) child.write(run.script);
|
|
1540
|
+
child.onData((data) => {
|
|
1541
|
+
if (webSocket.readyState === webSocket.OPEN) webSocket.send(JSON.stringify({
|
|
1542
|
+
type: "output",
|
|
1543
|
+
data
|
|
1544
|
+
}));
|
|
1545
|
+
});
|
|
1546
|
+
child.onExit(({ exitCode }) => {
|
|
1547
|
+
if (inlineFile) rm(inlineFile, { force: true });
|
|
1548
|
+
if (webSocket.readyState === webSocket.OPEN) {
|
|
1549
|
+
webSocket.send(JSON.stringify({
|
|
1550
|
+
type: "exit",
|
|
1551
|
+
exitCode
|
|
1552
|
+
}));
|
|
1553
|
+
webSocket.close(1e3, String(exitCode));
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
return child;
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
if (inlineFile) await rm(inlineFile, { force: true });
|
|
1559
|
+
if (webSocket.readyState === webSocket.OPEN) {
|
|
1560
|
+
webSocket.send(JSON.stringify({
|
|
1561
|
+
type: "error",
|
|
1562
|
+
message: message(error)
|
|
1563
|
+
}));
|
|
1564
|
+
webSocket.close(1011, "Runnable failed");
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
server.on("upgrade", (request, socket, head) => {
|
|
1569
|
+
if (new URL(request.url ?? "", "http://localhost").pathname !== "/api/run") {
|
|
1570
|
+
socket.destroy();
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
if (!isSameOrigin(request.headers.origin, request.headers.host, host !== "127.0.0.1")) {
|
|
1574
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
runSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
|
1578
|
+
runSockets.emit("connection", webSocket, request);
|
|
1579
|
+
});
|
|
1580
|
+
});
|
|
1581
|
+
runSockets.on("connection", (webSocket) => {
|
|
1582
|
+
let child;
|
|
1583
|
+
let closed = false;
|
|
1584
|
+
const pendingInput = [];
|
|
1585
|
+
let pendingSize;
|
|
1586
|
+
let starting = false;
|
|
1587
|
+
webSocket.on("message", (data) => {
|
|
1588
|
+
const event = parseRunClientEvent(rawDataText(data));
|
|
1589
|
+
if (!event) {
|
|
1590
|
+
webSocket.close(1008, "Invalid runnable message");
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
try {
|
|
1594
|
+
if (event.type === "start" && !child && !starting) {
|
|
1595
|
+
starting = true;
|
|
1596
|
+
startRun(webSocket, event.selection, {
|
|
1597
|
+
cols: Math.max(1, Math.min(500, event.cols)),
|
|
1598
|
+
rows: Math.max(1, Math.min(200, event.rows))
|
|
1599
|
+
}).then((process) => {
|
|
1600
|
+
if (closed) {
|
|
1601
|
+
process?.kill();
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
child = process;
|
|
1605
|
+
if (pendingSize) child?.resize(pendingSize.cols, pendingSize.rows);
|
|
1606
|
+
for (const input of pendingInput) child?.write(input);
|
|
1607
|
+
});
|
|
1608
|
+
} else if (event.type === "input") {
|
|
1609
|
+
if (child) child.write(event.data);
|
|
1610
|
+
else if (starting) pendingInput.push(event.data);
|
|
1611
|
+
} else if (event.type === "resize") {
|
|
1612
|
+
const size = {
|
|
1613
|
+
cols: Math.max(1, Math.min(500, event.cols)),
|
|
1614
|
+
rows: Math.max(1, Math.min(200, event.rows))
|
|
1615
|
+
};
|
|
1616
|
+
if (child) child.resize(size.cols, size.rows);
|
|
1617
|
+
else if (starting) pendingSize = size;
|
|
1618
|
+
}
|
|
1619
|
+
} catch {
|
|
1620
|
+
webSocket.close(1008, "Invalid runnable message");
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
webSocket.on("close", () => {
|
|
1624
|
+
closed = true;
|
|
1625
|
+
child?.kill();
|
|
1626
|
+
});
|
|
1627
|
+
});
|
|
1628
|
+
const port = options.port ?? config.port ?? 6292;
|
|
1629
|
+
const host = options.host ?? config.host ?? "0.0.0.0";
|
|
1630
|
+
await new Promise((resolve, reject) => server.listen(port, host, resolve).once("error", reject));
|
|
1631
|
+
return {
|
|
1632
|
+
server,
|
|
1633
|
+
vite,
|
|
1634
|
+
url: `http://${host === "0.0.0.0" || host === "::" ? "localhost" : host}:${port}`,
|
|
1635
|
+
async close() {
|
|
1636
|
+
for (const webSocket of runSockets.clients) webSocket.close(1001, "Server shutting down");
|
|
1637
|
+
await agentManager.close();
|
|
1638
|
+
runSockets.close();
|
|
1639
|
+
await watcher.close();
|
|
1640
|
+
await vite.close();
|
|
1641
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
1642
|
+
}
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
//#endregion
|
|
1646
|
+
export { assertGitRepository as n, createNawcServer as t };
|