agent.libx.js 0.86.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/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/Agent-WTkHB8RY.d.ts +319 -0
- package/dist/cli.d.ts +164 -0
- package/dist/cli.js +6876 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +621 -0
- package/dist/index.js +3605 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-Dg3vA1Uj.d.ts +42 -0
- package/dist/mcp.client.d.ts +121 -0
- package/dist/mcp.client.js +261 -0
- package/dist/mcp.client.js.map +1 -0
- package/dist/tools-Ch-OzOU8.d.ts +255 -0
- package/dist/tools.shell.d.ts +80 -0
- package/dist/tools.shell.js +402 -0
- package/dist/tools.shell.js.map +1 -0
- package/package.json +79 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3605 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/redact.ts
|
|
12
|
+
function redactSecrets(text) {
|
|
13
|
+
if (!text) return text;
|
|
14
|
+
return text.replace(SECRET_PAIR, (_m, head, quote, _val) => `${head}${quote}${REDACTED}`).replace(SECRET_TOKEN, REDACTED);
|
|
15
|
+
}
|
|
16
|
+
var REDACTED, CONFIG_FILE_RE, SECRET_PAIR, SECRET_TOKEN;
|
|
17
|
+
var init_redact = __esm({
|
|
18
|
+
"src/redact.ts"() {
|
|
19
|
+
"use strict";
|
|
20
|
+
REDACTED = "\u2039redacted\u203A";
|
|
21
|
+
CONFIG_FILE_RE = /(^|\/)\.(agent|claude)\/(settings(\.[\w-]+)?\.json|config\.(json|js|mjs|cjs|ts))$/i;
|
|
22
|
+
SECRET_PAIR = /((?:^|[\s,{[])(?:export\s+)?["']?[\w.\-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|ACCESS_?KEY|AUTH(?:_?TOKEN)?|BEARER)[\w.\-]*["']?\s*[:=]\s*)(["']?)([^\s"',{}\]]+)/gi;
|
|
23
|
+
SECRET_TOKEN = /\b(sk-ant-[\w-]{12,}|sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[\w]{20,}|xox[baprs]-[\w-]{10,}|AKIA[0-9A-Z]{12,}|AIza[\w-]{20,}|eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,})\b/g;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// src/tools.structured.ts
|
|
28
|
+
async function walkFiles(fs, dir, out = []) {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await fs.readDir(dir);
|
|
32
|
+
} catch {
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
for (const name of entries.sort()) {
|
|
36
|
+
const p = dir === "/" ? `/${name}` : `${dir}/${name}`;
|
|
37
|
+
if (await fs.isDirectory(p)) await walkFiles(fs, p, out);
|
|
38
|
+
else out.push(p);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function globToRegExp(glob, caseInsensitive = false) {
|
|
43
|
+
const g = glob.startsWith("/") ? glob : `/${glob}`;
|
|
44
|
+
let re = "";
|
|
45
|
+
for (let i = 0; i < g.length; i++) {
|
|
46
|
+
const c = g[i];
|
|
47
|
+
if (c === "*") {
|
|
48
|
+
if (g[i + 1] === "*") {
|
|
49
|
+
re += ".*";
|
|
50
|
+
i++;
|
|
51
|
+
if (g[i + 1] === "/") i++;
|
|
52
|
+
} else re += "[^/]*";
|
|
53
|
+
} else if (c === "?") re += "[^/]";
|
|
54
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
55
|
+
}
|
|
56
|
+
return new RegExp(`^${re}$`, caseInsensitive ? "i" : "");
|
|
57
|
+
}
|
|
58
|
+
function anchoredGlob(fs, glob) {
|
|
59
|
+
const cwd = fsCwd(fs);
|
|
60
|
+
const base = cwd === "/" ? "" : cwd;
|
|
61
|
+
return globToRegExp(glob.startsWith("/") ? glob : `${base}/${glob}`);
|
|
62
|
+
}
|
|
63
|
+
function docOutlineOf(content, cap = 20) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const lines = content.split("\n");
|
|
66
|
+
for (let i = 0; i < lines.length && out.length < cap; i++) {
|
|
67
|
+
const hm = lines[i].match(/^(#{1,4})\s+(.+)/);
|
|
68
|
+
if (hm) {
|
|
69
|
+
out.push(hm[0].slice(0, 120));
|
|
70
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
71
|
+
const l = lines[j].trim();
|
|
72
|
+
if (!l) continue;
|
|
73
|
+
if (l.startsWith("#")) break;
|
|
74
|
+
out.push(" " + l.slice(0, 120));
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function signaturesOf(content, cap = 40) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const line of content.split("\n")) {
|
|
84
|
+
if (!SIG_RE.test(line)) continue;
|
|
85
|
+
let sig = line.trim();
|
|
86
|
+
const brace = sig.indexOf("{");
|
|
87
|
+
if (brace > 0) sig = sig.slice(0, brace).trim();
|
|
88
|
+
sig = sig.replace(/\s*=>?\s*$/, "").replace(/=\s*$/, "").slice(0, 120);
|
|
89
|
+
if (sig && !out.includes(sig)) out.push(sig);
|
|
90
|
+
if (out.length >= cap) break;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
async function repoIndex(fs, glob, mode = "code") {
|
|
95
|
+
const scope = glob ? anchoredGlob(fs, String(glob)) : null;
|
|
96
|
+
const filter = mode === "code" ? isCode : mode === "docs" ? isDoc : (p) => isCode(p) || isDoc(p);
|
|
97
|
+
const files = (await walkFiles(fs, fsCwd(fs))).filter((p) => scope ? scope.test(p) : filter(p));
|
|
98
|
+
const blocks = [];
|
|
99
|
+
let shown = 0;
|
|
100
|
+
for (const path of files) {
|
|
101
|
+
let content;
|
|
102
|
+
try {
|
|
103
|
+
content = await fs.readFile(path);
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const entries = isDoc(path) ? docOutlineOf(content) : signaturesOf(content);
|
|
108
|
+
if (entries.length) {
|
|
109
|
+
blocks.push(`${path}
|
|
110
|
+
${entries.map((s) => " " + s).join("\n")}`);
|
|
111
|
+
shown += entries.length;
|
|
112
|
+
}
|
|
113
|
+
if (shown >= 400) {
|
|
114
|
+
blocks.push("\u2026 (map truncated; narrow with `glob`)");
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const label = mode === "code" ? "code signatures" : mode === "docs" ? "document outlines" : "entries";
|
|
119
|
+
return blocks.length ? blocks.join("\n") : `(no ${label} found)`;
|
|
120
|
+
}
|
|
121
|
+
function fuzzyLineReplace(content, oldStr, newStr) {
|
|
122
|
+
const norm2 = (s) => s.trim();
|
|
123
|
+
const cl = content.split("\n");
|
|
124
|
+
const ol = oldStr.split("\n").map(norm2);
|
|
125
|
+
while (ol.length && ol[ol.length - 1] === "") ol.pop();
|
|
126
|
+
while (ol.length && ol[0] === "") ol.shift();
|
|
127
|
+
if (!ol.length) return null;
|
|
128
|
+
const matches = [];
|
|
129
|
+
for (let i2 = 0; i2 + ol.length <= cl.length; i2++) {
|
|
130
|
+
let ok = true;
|
|
131
|
+
for (let j = 0; j < ol.length; j++) if (norm2(cl[i2 + j]) !== ol[j]) {
|
|
132
|
+
ok = false;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (ok) matches.push(i2);
|
|
136
|
+
}
|
|
137
|
+
if (matches.length !== 1) return null;
|
|
138
|
+
const i = matches[0];
|
|
139
|
+
return [...cl.slice(0, i), ...newStr.split("\n"), ...cl.slice(i + ol.length)].join("\n");
|
|
140
|
+
}
|
|
141
|
+
function parentDir(abs) {
|
|
142
|
+
const i = abs.lastIndexOf("/");
|
|
143
|
+
return i <= 0 ? "/" : abs.slice(0, i);
|
|
144
|
+
}
|
|
145
|
+
async function mkdirp(fs, dir) {
|
|
146
|
+
if (dir === "/" || await fs.exists(dir)) return;
|
|
147
|
+
await mkdirp(fs, parentDir(dir));
|
|
148
|
+
if (!await fs.exists(dir)) await fs.createDir(dir);
|
|
149
|
+
}
|
|
150
|
+
function reviewTool() {
|
|
151
|
+
return {
|
|
152
|
+
name: "Review",
|
|
153
|
+
description: 'Critically review your changes before finishing (verification without running code). Pass the task and the paths you changed; a fresh-context senior reviewer reads them COLD and returns concrete issues or "LGTM". Fix what it raises, then finish. Optional `notes` feeds the reviewer extra context.',
|
|
154
|
+
parameters: {
|
|
155
|
+
type: "object",
|
|
156
|
+
required: ["task", "paths"],
|
|
157
|
+
properties: {
|
|
158
|
+
task: { type: "string", description: "what was asked \u2014 the spec/requirements to check the changes against" },
|
|
159
|
+
paths: { type: "array", items: { type: "string" }, description: "the files you changed/created, to be reviewed" },
|
|
160
|
+
notes: { type: "string", description: "OPTIONAL extra context for the reviewer (rationale, constraints). Omit for a pure cold review." }
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
async run({ task, paths, notes }, ctx) {
|
|
164
|
+
if (!ctx.ai || !ctx.model) return "[Review] no model handle wired \u2014 skipped.";
|
|
165
|
+
const list = Array.isArray(paths) ? paths.map(String) : [];
|
|
166
|
+
if (!list.length) return "Error: pass the paths you changed in `paths`.";
|
|
167
|
+
const files = [];
|
|
168
|
+
for (const p of list.slice(0, 12)) {
|
|
169
|
+
try {
|
|
170
|
+
const body = await ctx.fs.readFile(p);
|
|
171
|
+
files.push(`--- ${p} ---
|
|
172
|
+
${body.length > 4e3 ? body.slice(0, 4e3) + "\n\u2026(truncated)" : body}`);
|
|
173
|
+
} catch {
|
|
174
|
+
files.push(`--- ${p} ---
|
|
175
|
+
[could not read]`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const prompt = `You are a senior engineer doing a critical code review. Review the changes below AGAINST THE TASK with deep, skeptical thinking \u2014 default to finding problems.
|
|
179
|
+
Focus on: correctness, edge cases (empty/boundary/negative inputs), unstated-but-implied requirements, and subtle logic bugs. Do NOT comment on style.
|
|
180
|
+
|
|
181
|
+
TASK:
|
|
182
|
+
${String(task ?? "").trim()}
|
|
183
|
+
|
|
184
|
+
` + (notes ? `CONTEXT FROM THE AUTHOR:
|
|
185
|
+
${String(notes).trim()}
|
|
186
|
+
|
|
187
|
+
` : "") + `CHANGED FILES:
|
|
188
|
+
${files.join("\n\n")}
|
|
189
|
+
|
|
190
|
+
Reply with a short numbered list of concrete, actionable issues. If the changes correctly and completely satisfy the task with no edge cases missed, reply with exactly: LGTM`;
|
|
191
|
+
try {
|
|
192
|
+
const r = await ctx.ai.chat({ model: ctx.model, messages: [{ role: "user", content: prompt }], stream: false });
|
|
193
|
+
const text = (r?.content ?? "").trim();
|
|
194
|
+
return text || "LGTM";
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return `[Review] model error: ${e?.message ?? e} \u2014 skipped.`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
var fsCwd, globTool, grepTool, SIG_RE, isCode, isDoc, repoMapTool, writeTool, multiEditTool, applyEditsTool;
|
|
202
|
+
var init_tools_structured = __esm({
|
|
203
|
+
"src/tools.structured.ts"() {
|
|
204
|
+
"use strict";
|
|
205
|
+
init_redact();
|
|
206
|
+
fsCwd = (fs) => fs.getCwd();
|
|
207
|
+
globTool = {
|
|
208
|
+
name: "Glob",
|
|
209
|
+
description: 'Find files by glob pattern (e.g. "**/*.ts", "src/**/*.test.ts"). Returns sorted paths, one per line. Space-separated patterns combine; `!`-prefix excludes (e.g. "**/*.ts !**/*.test.ts"). Prefer over `bash find` for locating files \u2014 one call, structured output.',
|
|
210
|
+
parameters: {
|
|
211
|
+
type: "object",
|
|
212
|
+
required: ["pattern"],
|
|
213
|
+
properties: { pattern: { type: "string", description: "glob pattern(s); ** matches across directories; prefix a pattern with ! to exclude" } }
|
|
214
|
+
},
|
|
215
|
+
async run({ pattern }, ctx) {
|
|
216
|
+
const pats = String(pattern ?? "").trim().split(/\s+/).filter(Boolean);
|
|
217
|
+
const include = pats.filter((p) => !p.startsWith("!")).map((p) => anchoredGlob(ctx.fs, p));
|
|
218
|
+
const exclude = pats.filter((p) => p.startsWith("!")).map((p) => anchoredGlob(ctx.fs, p.slice(1)));
|
|
219
|
+
const includes = include.length ? include : [anchoredGlob(ctx.fs, "**")];
|
|
220
|
+
const hits = (await walkFiles(ctx.fs, fsCwd(ctx.fs))).filter(
|
|
221
|
+
(p) => includes.some((re) => re.test(p)) && !exclude.some((re) => re.test(p))
|
|
222
|
+
);
|
|
223
|
+
return hits.length ? hits.join("\n") : "(no matches)";
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
grepTool = {
|
|
227
|
+
name: "Grep",
|
|
228
|
+
description: "Search file contents by regex. Returns `path:line: text` hits. Optional `glob` to scope files, `context` for surrounding lines, `filesOnly` for matching paths only. Prefer over `bash grep` for file content search \u2014 structured results, no re-parse needed. Use `bash` instead for running commands, tests, or piped workflows.",
|
|
229
|
+
parameters: {
|
|
230
|
+
type: "object",
|
|
231
|
+
required: ["pattern"],
|
|
232
|
+
properties: {
|
|
233
|
+
pattern: { type: "string", description: "JS regular expression" },
|
|
234
|
+
glob: { type: "string", description: "optional file glob to restrict the search" },
|
|
235
|
+
context: { type: "number", description: "lines of context before/after each hit" },
|
|
236
|
+
filesOnly: { type: "boolean", description: "only list matching file paths" }
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
async run({ pattern, glob, context, filesOnly }, ctx) {
|
|
240
|
+
let re;
|
|
241
|
+
try {
|
|
242
|
+
re = new RegExp(String(pattern ?? ""));
|
|
243
|
+
} catch (e) {
|
|
244
|
+
throw new Error(`invalid regex: ${String(e)}`);
|
|
245
|
+
}
|
|
246
|
+
const scope = glob ? anchoredGlob(ctx.fs, String(glob)) : null;
|
|
247
|
+
const files = (await walkFiles(ctx.fs, fsCwd(ctx.fs))).filter((p) => !scope || scope.test(p));
|
|
248
|
+
const ctxN = Math.max(0, Number(context ?? 0));
|
|
249
|
+
const out = [];
|
|
250
|
+
const matched = [];
|
|
251
|
+
for (const path of files) {
|
|
252
|
+
let content;
|
|
253
|
+
try {
|
|
254
|
+
content = await ctx.fs.readFile(path);
|
|
255
|
+
} catch {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const lines = content.split("\n");
|
|
259
|
+
const mask = CONFIG_FILE_RE.test(path);
|
|
260
|
+
let fileHit = false;
|
|
261
|
+
for (let i = 0; i < lines.length; i++) {
|
|
262
|
+
if (!re.test(lines[i])) continue;
|
|
263
|
+
fileHit = true;
|
|
264
|
+
if (filesOnly) break;
|
|
265
|
+
const lo = Math.max(0, i - ctxN), hi = Math.min(lines.length - 1, i + ctxN);
|
|
266
|
+
for (let j = lo; j <= hi; j++) out.push(`${path}:${j + 1}: ${mask ? redactSecrets(lines[j]) : lines[j]}`);
|
|
267
|
+
}
|
|
268
|
+
if (fileHit) matched.push(path);
|
|
269
|
+
}
|
|
270
|
+
if (filesOnly) return matched.length ? matched.join("\n") : "(no matches)";
|
|
271
|
+
return out.length ? out.join("\n") : "(no matches)";
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
SIG_RE = /^\s*(export\b|(?:export\s+)?(?:async\s+)?function\s+\*?\w|(?:export\s+)?(?:abstract\s+)?class\s+\w|(?:export\s+)?interface\s+\w|(?:export\s+)?type\s+\w|(?:export\s+)?enum\s+\w)/;
|
|
275
|
+
isCode = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
|
|
276
|
+
isDoc = (p) => /\.(md|mdx|txt)$/.test(p);
|
|
277
|
+
repoMapTool = {
|
|
278
|
+
name: "RepoMap",
|
|
279
|
+
description: 'Get a compact map of the workspace: code signatures and/or document outlines in ONE call. `scope`: "code" (default) = functions/classes/types; "docs" = markdown headings + summaries; "all" = both. Call once to orient before diving into specific files \u2014 avoids many exploratory Glob/Read calls.',
|
|
280
|
+
parameters: {
|
|
281
|
+
type: "object",
|
|
282
|
+
properties: {
|
|
283
|
+
glob: { type: "string", description: "optional file glob to scope (default: all matching files)" },
|
|
284
|
+
scope: { type: "string", enum: ["code", "docs", "all"], description: 'what to map: "code" (default), "docs", or "all"' }
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
run: ({ glob, scope }, ctx) => repoIndex(ctx.fs, glob, scope || "code")
|
|
288
|
+
};
|
|
289
|
+
writeTool = {
|
|
290
|
+
name: "Write",
|
|
291
|
+
description: "Create or overwrite a file with the given contents, creating parent directories as needed. Use for new files instead of `bash echo >`.",
|
|
292
|
+
parameters: {
|
|
293
|
+
type: "object",
|
|
294
|
+
required: ["path", "content"],
|
|
295
|
+
properties: { path: { type: "string" }, content: { type: "string" } }
|
|
296
|
+
},
|
|
297
|
+
async run({ path, content }, ctx) {
|
|
298
|
+
const body = String(content ?? "");
|
|
299
|
+
if (ctx.lint) {
|
|
300
|
+
const err = ctx.lint(path, body);
|
|
301
|
+
if (err) throw new Error(err);
|
|
302
|
+
}
|
|
303
|
+
await mkdirp(ctx.fs, parentDir(ctx.fs.resolvePath(path)));
|
|
304
|
+
await ctx.fs.writeFile(path, body);
|
|
305
|
+
ctx.readState.set(ctx.fs.resolvePath(path), body);
|
|
306
|
+
return `Wrote ${path}`;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
multiEditTool = {
|
|
310
|
+
name: "MultiEdit",
|
|
311
|
+
description: "Apply several exact-substring replacements to one file in order, in a single call. Requires a prior Read. Each `old_string` must be unique at the time it is applied. All-or-nothing: if any edit fails, none are written.",
|
|
312
|
+
parameters: {
|
|
313
|
+
type: "object",
|
|
314
|
+
required: ["path", "edits"],
|
|
315
|
+
properties: {
|
|
316
|
+
path: { type: "string" },
|
|
317
|
+
edits: {
|
|
318
|
+
type: "array",
|
|
319
|
+
items: {
|
|
320
|
+
type: "object",
|
|
321
|
+
required: ["old_string", "new_string"],
|
|
322
|
+
properties: { old_string: { type: "string" }, new_string: { type: "string" } }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
async run({ path, edits }, ctx) {
|
|
328
|
+
const key = ctx.fs.resolvePath(path);
|
|
329
|
+
const snapshot = ctx.readState.get(key);
|
|
330
|
+
if (snapshot == null) throw new Error(`File has not been read yet: ${path}. Read it before editing.`);
|
|
331
|
+
let current = await ctx.fs.readFile(path);
|
|
332
|
+
if (current !== snapshot) throw new Error(`File ${path} changed since it was read (stale). Re-read before editing.`);
|
|
333
|
+
const list = Array.isArray(edits) ? edits : [];
|
|
334
|
+
if (!list.length) throw new Error("edits must be a non-empty array");
|
|
335
|
+
for (const [i, e] of list.entries()) {
|
|
336
|
+
const count = e.old_string === "" ? 0 : current.split(e.old_string).length - 1;
|
|
337
|
+
if (count === 0) throw new Error(`edit ${i}: old_string not found in ${path}.`);
|
|
338
|
+
if (count > 1) throw new Error(`edit ${i}: old_string is not unique in ${path} (${count} matches).`);
|
|
339
|
+
current = current.replace(e.old_string, () => e.new_string);
|
|
340
|
+
}
|
|
341
|
+
if (ctx.lint) {
|
|
342
|
+
const err = ctx.lint(path, current);
|
|
343
|
+
if (err) throw new Error(err);
|
|
344
|
+
}
|
|
345
|
+
await ctx.fs.writeFile(path, current);
|
|
346
|
+
ctx.readState.set(key, current);
|
|
347
|
+
return `Applied ${list.length} edit(s) to ${path}`;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
applyEditsTool = {
|
|
351
|
+
name: "ApplyEdits",
|
|
352
|
+
description: "Apply edits across one or MORE files in a single call \u2014 for cross-file refactors (rename/extract/move). edits=[{path, old_string?, new_string}]. WITH old_string: replace that exact substring (must be UNIQUE in the file \u2014 add surrounding context; read fresh + verified, no prior Read needed). WITHOUT old_string: write new_string as the whole file (creates it + parent dirs). Locate sites first with Grep (its output shows the exact text). Atomic: validated across all files before any write.",
|
|
353
|
+
parameters: {
|
|
354
|
+
type: "object",
|
|
355
|
+
required: ["edits"],
|
|
356
|
+
properties: {
|
|
357
|
+
edits: {
|
|
358
|
+
type: "array",
|
|
359
|
+
items: {
|
|
360
|
+
type: "object",
|
|
361
|
+
required: ["path", "new_string"],
|
|
362
|
+
properties: { path: { type: "string" }, old_string: { type: "string" }, new_string: { type: "string" } }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
async run({ edits }, ctx) {
|
|
368
|
+
const list = Array.isArray(edits) ? edits : [];
|
|
369
|
+
if (!list.length) throw new Error("edits must be a non-empty array of {path, old_string?, new_string}");
|
|
370
|
+
const planned = /* @__PURE__ */ new Map();
|
|
371
|
+
for (const [i, e] of list.entries()) {
|
|
372
|
+
const p = ctx.fs.resolvePath(String(e.path));
|
|
373
|
+
const old = e.old_string == null ? "" : String(e.old_string);
|
|
374
|
+
const neu = String(e.new_string ?? "");
|
|
375
|
+
if (old === "") {
|
|
376
|
+
planned.set(p, neu);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
let cur = planned.has(p) ? planned.get(p) : await ctx.fs.readFile(p).catch(() => {
|
|
380
|
+
throw new Error(`edit ${i}: file not found: ${e.path}`);
|
|
381
|
+
});
|
|
382
|
+
const count = cur.split(old).length - 1;
|
|
383
|
+
if (count > 1) throw new Error(`edit ${i}: old_string is not unique in ${e.path} (${count} matches) \u2014 add more context`);
|
|
384
|
+
if (count === 1) cur = cur.replace(old, () => neu);
|
|
385
|
+
else {
|
|
386
|
+
const fz = fuzzyLineReplace(cur, old, neu);
|
|
387
|
+
if (fz == null) throw new Error(`edit ${i}: old_string not found in ${e.path}`);
|
|
388
|
+
cur = fz;
|
|
389
|
+
}
|
|
390
|
+
planned.set(p, cur);
|
|
391
|
+
}
|
|
392
|
+
if (ctx.lint) for (const [p, content] of planned) {
|
|
393
|
+
const err = ctx.lint(p, content);
|
|
394
|
+
if (err) throw new Error(err);
|
|
395
|
+
}
|
|
396
|
+
for (const [p, content] of planned) {
|
|
397
|
+
await mkdirp(ctx.fs, parentDir(p));
|
|
398
|
+
await ctx.fs.writeFile(p, content);
|
|
399
|
+
ctx.readState.set(p, content);
|
|
400
|
+
}
|
|
401
|
+
return `Applied ${list.length} edit(s) across ${planned.size} file(s): ${[...planned.keys()].join(", ")}`;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// src/todo.ts
|
|
408
|
+
function renderTodos(todos) {
|
|
409
|
+
if (todos.length === 0) return "Todo list cleared (no items).";
|
|
410
|
+
return todos.map((t) => `${MARK[t.status]} ${t.content}`).join("\n");
|
|
411
|
+
}
|
|
412
|
+
var MARK, todoWriteTool;
|
|
413
|
+
var init_todo = __esm({
|
|
414
|
+
"src/todo.ts"() {
|
|
415
|
+
"use strict";
|
|
416
|
+
MARK = {
|
|
417
|
+
pending: "[ ]",
|
|
418
|
+
in_progress: "[~]",
|
|
419
|
+
completed: "[x]"
|
|
420
|
+
};
|
|
421
|
+
todoWriteTool = {
|
|
422
|
+
name: "TodoWrite",
|
|
423
|
+
description: "Maintain a todo list to plan and track a multi-step task. Replaces the current list with the provided todos and returns the rendered checklist. Mark exactly one item in_progress at a time; flip items to completed as you finish them.",
|
|
424
|
+
parameters: {
|
|
425
|
+
type: "object",
|
|
426
|
+
required: ["todos"],
|
|
427
|
+
properties: {
|
|
428
|
+
todos: {
|
|
429
|
+
type: "array",
|
|
430
|
+
items: {
|
|
431
|
+
type: "object",
|
|
432
|
+
required: ["content", "status"],
|
|
433
|
+
properties: {
|
|
434
|
+
content: { type: "string", description: "the step to do" },
|
|
435
|
+
status: { type: "string", enum: ["pending", "in_progress", "completed"] }
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
async run({ todos }, ctx) {
|
|
442
|
+
if (!Array.isArray(todos)) throw new Error("todos must be an array of { content, status } items.");
|
|
443
|
+
const next = todos.map((t, i) => {
|
|
444
|
+
const content = String(t?.content ?? "").trim();
|
|
445
|
+
if (!content) throw new Error(`todos[${i}].content is required.`);
|
|
446
|
+
const status = t?.status;
|
|
447
|
+
if (status !== "pending" && status !== "in_progress" && status !== "completed") {
|
|
448
|
+
throw new Error(`todos[${i}].status must be one of pending|in_progress|completed (got ${JSON.stringify(status)}).`);
|
|
449
|
+
}
|
|
450
|
+
return { content, status };
|
|
451
|
+
});
|
|
452
|
+
ctx.todos = next;
|
|
453
|
+
return renderTodos(next);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// src/logging.ts
|
|
460
|
+
import { log } from "libx.js/src/modules/log";
|
|
461
|
+
var forComponent;
|
|
462
|
+
var init_logging = __esm({
|
|
463
|
+
"src/logging.ts"() {
|
|
464
|
+
"use strict";
|
|
465
|
+
forComponent = (name) => log.forComponent(name);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// src/tools.web.ts
|
|
470
|
+
function htmlToText(html) {
|
|
471
|
+
let s = html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<title[\s\S]*?<\/title>/gi, " ").replace(/<noscript[\s\S]*?<\/noscript>/gi, " ").replace(/<textarea[\s\S]*?<\/textarea>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<\/(p|div|li|h[1-6]|tr|section|article|header|footer|nav)>/gi, "\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ");
|
|
472
|
+
s = s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/�?39;/g, "'").replace(/'/gi, "'");
|
|
473
|
+
return s.replace(/[ \t\f\v]+/g, " ").split("\n").map((l) => l.trim()).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
474
|
+
}
|
|
475
|
+
function isPrivateHost(host) {
|
|
476
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
477
|
+
if (h === "" || h === "localhost" || h.endsWith(".localhost") || h.endsWith(".internal")) return true;
|
|
478
|
+
if (h === "::1" || h === "::" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) return true;
|
|
479
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
480
|
+
if (m) {
|
|
481
|
+
const a = +m[1], b = +m[2];
|
|
482
|
+
return a === 0 || a === 127 || a === 10 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
async function resolveIps(host) {
|
|
487
|
+
if (_dnsLookup === void 0) {
|
|
488
|
+
try {
|
|
489
|
+
_dnsLookup = (await import("dns/promises")).lookup;
|
|
490
|
+
} catch {
|
|
491
|
+
_dnsLookup = null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (!_dnsLookup) return null;
|
|
495
|
+
try {
|
|
496
|
+
return (await _dnsLookup(host, { all: true })).map((a) => a.address);
|
|
497
|
+
} catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async function readCapped(res, maxBytes) {
|
|
502
|
+
const reader = res.body?.getReader?.();
|
|
503
|
+
if (!reader) {
|
|
504
|
+
const t = await res.text();
|
|
505
|
+
return t.length > maxBytes ? t.slice(0, maxBytes) : t;
|
|
506
|
+
}
|
|
507
|
+
const chunks = [];
|
|
508
|
+
let total = 0;
|
|
509
|
+
for (; ; ) {
|
|
510
|
+
const { done, value } = await reader.read();
|
|
511
|
+
if (done) break;
|
|
512
|
+
if (value) {
|
|
513
|
+
chunks.push(value);
|
|
514
|
+
total += value.length;
|
|
515
|
+
}
|
|
516
|
+
if (total >= maxBytes) {
|
|
517
|
+
try {
|
|
518
|
+
await reader.cancel();
|
|
519
|
+
} catch {
|
|
520
|
+
}
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const out = new Uint8Array(Math.min(total, maxBytes));
|
|
525
|
+
let off = 0;
|
|
526
|
+
for (const c of chunks) {
|
|
527
|
+
if (off >= out.length) break;
|
|
528
|
+
const take = Math.min(c.length, out.length - off);
|
|
529
|
+
out.set(c.subarray(0, take), off);
|
|
530
|
+
off += take;
|
|
531
|
+
}
|
|
532
|
+
return new TextDecoder().decode(out);
|
|
533
|
+
}
|
|
534
|
+
function makeWebFetchTool(options = {}) {
|
|
535
|
+
const maxBytes = options.maxBytes ?? 2e6;
|
|
536
|
+
const maxChars = options.maxChars ?? 1e5;
|
|
537
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
538
|
+
return {
|
|
539
|
+
name: "WebFetch",
|
|
540
|
+
description: "Fetch an http/https URL and return its readable text (HTML is stripped to text). Use to read docs or web pages. Returns the status line then up to ~100k chars of content.",
|
|
541
|
+
parameters: { type: "object", required: ["url"], properties: { url: { type: "string", description: "absolute http(s) URL" } } },
|
|
542
|
+
async run({ url }) {
|
|
543
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
544
|
+
const customFetch = !!options.fetch;
|
|
545
|
+
const u = String(url ?? "");
|
|
546
|
+
try {
|
|
547
|
+
new URL(u);
|
|
548
|
+
} catch {
|
|
549
|
+
return `Error: invalid URL: ${u}`;
|
|
550
|
+
}
|
|
551
|
+
if (!doFetch) return "Error: no network (fetch) available in this runtime";
|
|
552
|
+
const hostBlock = async (hostname) => {
|
|
553
|
+
if (options.allowPrivateHosts) return null;
|
|
554
|
+
if (isPrivateHost(hostname)) return hostname;
|
|
555
|
+
if (!customFetch) {
|
|
556
|
+
const ips = await resolveIps(hostname);
|
|
557
|
+
if (ips) {
|
|
558
|
+
for (const ip of ips) if (isPrivateHost(ip)) return `${hostname} \u2192 ${ip}`;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
};
|
|
563
|
+
const ctl = new AbortController();
|
|
564
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
565
|
+
try {
|
|
566
|
+
let current = u;
|
|
567
|
+
let res;
|
|
568
|
+
for (let hop = 0; ; hop++) {
|
|
569
|
+
const pu = new URL(current);
|
|
570
|
+
if (pu.protocol !== "http:" && pu.protocol !== "https:") return `Error: only http/https URLs are allowed (got "${pu.protocol}")`;
|
|
571
|
+
const blocked = await hostBlock(pu.hostname);
|
|
572
|
+
if (blocked) return `Error: refusing to fetch a private/internal address (${blocked}) \u2014 set allowPrivateHosts to override`;
|
|
573
|
+
res = await doFetch(current, { signal: ctl.signal, redirect: "manual", headers: { "user-agent": "agentx (+https://github.com/Livshitz/agent.libx.js)" } });
|
|
574
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
|
|
575
|
+
if (hop >= 5) return `Error fetching ${u}: too many redirects`;
|
|
576
|
+
current = new URL(res.headers.get("location"), current).toString();
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
const type = res.headers.get("content-type") ?? "";
|
|
582
|
+
const body = await readCapped(res, maxBytes);
|
|
583
|
+
const text = /html/i.test(type) || /^\s*<(?:!doctype|html)/i.test(body) ? htmlToText(body) : body.trim();
|
|
584
|
+
const capped = text.length > maxChars ? text.slice(0, maxChars) + `
|
|
585
|
+
\u2026 [truncated at ${maxChars} chars]` : text;
|
|
586
|
+
return `${res.status} ${res.statusText} \xB7 ${new URL(current).host}
|
|
587
|
+
|
|
588
|
+
${capped}`;
|
|
589
|
+
} catch (e) {
|
|
590
|
+
log2.debug(`WebFetch ${u} failed`, e);
|
|
591
|
+
return `Error fetching ${u}: ${e?.name === "AbortError" ? `timed out after ${timeoutMs}ms` : e?.message ?? e}`;
|
|
592
|
+
} finally {
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function makeWebSearchTool(options = {}) {
|
|
599
|
+
const endpoint = options.endpoint ?? "https://api.tavily.com/search";
|
|
600
|
+
const maxResults = options.maxResults ?? 5;
|
|
601
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
602
|
+
return {
|
|
603
|
+
name: "WebSearch",
|
|
604
|
+
description: "Search the web; returns ranked results (title, URL, snippet). Requires a configured provider \u2014 set TAVILY_API_KEY to enable.",
|
|
605
|
+
parameters: { type: "object", required: ["query"], properties: { query: { type: "string" } } },
|
|
606
|
+
async run({ query }) {
|
|
607
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
608
|
+
const key = options.apiKey ?? process.env.TAVILY_API_KEY;
|
|
609
|
+
if (!key) return "Error: WebSearch is not configured. Set TAVILY_API_KEY (https://tavily.com) to enable web search.";
|
|
610
|
+
if (!doFetch) return "Error: no network (fetch) available in this runtime";
|
|
611
|
+
const ctl = new AbortController();
|
|
612
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
613
|
+
try {
|
|
614
|
+
const res = await doFetch(endpoint, {
|
|
615
|
+
method: "POST",
|
|
616
|
+
signal: ctl.signal,
|
|
617
|
+
headers: { "content-type": "application/json" },
|
|
618
|
+
body: JSON.stringify({ api_key: key, query: String(query ?? ""), max_results: maxResults })
|
|
619
|
+
});
|
|
620
|
+
if (!res.ok) return `Error: search provider returned ${res.status} ${res.statusText}`;
|
|
621
|
+
const data = await res.json();
|
|
622
|
+
const results = Array.isArray(data?.results) ? data.results.slice(0, maxResults) : [];
|
|
623
|
+
if (!results.length) return "(no results)";
|
|
624
|
+
return results.map((r, i) => `${i + 1}. ${r.title ?? "(untitled)"}
|
|
625
|
+
${r.url ?? ""}
|
|
626
|
+
${String(r.content ?? "").replace(/\s+/g, " ").slice(0, 240)}`).join("\n\n");
|
|
627
|
+
} catch (e) {
|
|
628
|
+
log2.debug("WebSearch failed", e);
|
|
629
|
+
return `Error searching: ${e?.name === "AbortError" ? `timed out after ${timeoutMs}ms` : e?.message ?? e}`;
|
|
630
|
+
} finally {
|
|
631
|
+
clearTimeout(timer);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
var log2, _dnsLookup, webFetchTool, webSearchTool;
|
|
637
|
+
var init_tools_web = __esm({
|
|
638
|
+
"src/tools.web.ts"() {
|
|
639
|
+
"use strict";
|
|
640
|
+
init_logging();
|
|
641
|
+
log2 = forComponent("web");
|
|
642
|
+
webFetchTool = makeWebFetchTool();
|
|
643
|
+
webSearchTool = makeWebSearchTool();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// src/OverlayFilesystem.ts
|
|
648
|
+
var OverlayFilesystem, asCheckpointable, NEEDS_OVERLAY, checkpointTool, rollbackTool, checkpointTools;
|
|
649
|
+
var init_OverlayFilesystem = __esm({
|
|
650
|
+
"src/OverlayFilesystem.ts"() {
|
|
651
|
+
"use strict";
|
|
652
|
+
OverlayFilesystem = class {
|
|
653
|
+
constructor(base) {
|
|
654
|
+
this.base = base;
|
|
655
|
+
}
|
|
656
|
+
base;
|
|
657
|
+
st = { writes: /* @__PURE__ */ new Map(), deletes: /* @__PURE__ */ new Set(), dirs: /* @__PURE__ */ new Set() };
|
|
658
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
659
|
+
seq = 0;
|
|
660
|
+
// ---- path plumbing: delegate to the base so normalization/cwd are identical ----
|
|
661
|
+
resolvePath(path, cwd) {
|
|
662
|
+
return this.base.resolvePath(path, cwd ?? this.base.getCwd());
|
|
663
|
+
}
|
|
664
|
+
getCwd() {
|
|
665
|
+
return this.base.getCwd();
|
|
666
|
+
}
|
|
667
|
+
setCwd(path) {
|
|
668
|
+
this.base.setCwd(path);
|
|
669
|
+
}
|
|
670
|
+
abs(p) {
|
|
671
|
+
return this.resolvePath(p);
|
|
672
|
+
}
|
|
673
|
+
parent(abs) {
|
|
674
|
+
const i = abs.lastIndexOf("/");
|
|
675
|
+
return i <= 0 ? "/" : abs.slice(0, i);
|
|
676
|
+
}
|
|
677
|
+
name(abs) {
|
|
678
|
+
return abs.slice(abs.lastIndexOf("/") + 1);
|
|
679
|
+
}
|
|
680
|
+
// ---- reads (overlay wins; tombstones hide the base) ----
|
|
681
|
+
async readFile(path) {
|
|
682
|
+
const p = this.abs(path);
|
|
683
|
+
if (this.st.deletes.has(p)) throw new Error(`File not found: ${path}`);
|
|
684
|
+
if (this.st.writes.has(p)) return this.st.writes.get(p);
|
|
685
|
+
return this.base.readFile(p);
|
|
686
|
+
}
|
|
687
|
+
async exists(path) {
|
|
688
|
+
const p = this.abs(path);
|
|
689
|
+
if (this.st.deletes.has(p)) return false;
|
|
690
|
+
if (this.st.writes.has(p) || this.st.dirs.has(p)) return true;
|
|
691
|
+
return this.base.exists(p);
|
|
692
|
+
}
|
|
693
|
+
async isFile(path) {
|
|
694
|
+
const p = this.abs(path);
|
|
695
|
+
if (this.st.deletes.has(p)) return false;
|
|
696
|
+
if (this.st.writes.has(p)) return true;
|
|
697
|
+
if (this.st.dirs.has(p)) return false;
|
|
698
|
+
return this.base.isFile(p);
|
|
699
|
+
}
|
|
700
|
+
async isDirectory(path) {
|
|
701
|
+
const p = this.abs(path);
|
|
702
|
+
if (this.st.deletes.has(p)) return false;
|
|
703
|
+
if (this.st.dirs.has(p)) return true;
|
|
704
|
+
if (this.st.writes.has(p)) return false;
|
|
705
|
+
return this.base.isDirectory(p);
|
|
706
|
+
}
|
|
707
|
+
async stat(path) {
|
|
708
|
+
const p = this.abs(path);
|
|
709
|
+
if (this.st.deletes.has(p)) throw new Error(`File not found: ${path}`);
|
|
710
|
+
if (this.st.writes.has(p)) {
|
|
711
|
+
const size = this.st.writes.get(p).length;
|
|
712
|
+
return { created: /* @__PURE__ */ new Date(0), modified: /* @__PURE__ */ new Date(0), size, permissions: "-rw-r--r--", isExecutable: false };
|
|
713
|
+
}
|
|
714
|
+
if (this.st.dirs.has(p)) return { created: /* @__PURE__ */ new Date(0), modified: /* @__PURE__ */ new Date(0), size: 0, permissions: "drwxr-xr-x", isExecutable: false };
|
|
715
|
+
return this.base.stat(p);
|
|
716
|
+
}
|
|
717
|
+
/** Merge base entries (minus tombstones) with overlay-created children of `path`. */
|
|
718
|
+
async readDir(path) {
|
|
719
|
+
const p = this.abs(path);
|
|
720
|
+
if (await this.isFile(p)) throw new Error(`Not a directory: ${path}`);
|
|
721
|
+
const names = /* @__PURE__ */ new Set();
|
|
722
|
+
try {
|
|
723
|
+
for (const n of await this.base.readDir(p)) names.add(n);
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
726
|
+
for (const w of [...this.st.writes.keys(), ...this.st.dirs]) if (this.parent(w) === p) names.add(this.name(w));
|
|
727
|
+
for (const d of this.st.deletes) if (this.parent(d) === p) names.delete(this.name(d));
|
|
728
|
+
if (names.size === 0 && !await this.exists(p) && p !== "/") throw new Error(`Directory not found: ${path}`);
|
|
729
|
+
return [...names].sort();
|
|
730
|
+
}
|
|
731
|
+
// ---- writes (recorded in the overlay; base untouched until commit) ----
|
|
732
|
+
async writeFile(path, content) {
|
|
733
|
+
const p = this.abs(path);
|
|
734
|
+
if (!await this.exists(this.parent(p))) throw new Error(`Parent directory does not exist: ${path}`);
|
|
735
|
+
this.st.deletes.delete(p);
|
|
736
|
+
this.st.writes.set(p, content);
|
|
737
|
+
}
|
|
738
|
+
async createDir(path) {
|
|
739
|
+
const p = this.abs(path);
|
|
740
|
+
if (await this.exists(p)) throw new Error(`File or directory already exists: ${path}`);
|
|
741
|
+
if (!await this.exists(this.parent(p))) throw new Error(`Parent directory does not exist: ${path}`);
|
|
742
|
+
this.st.deletes.delete(p);
|
|
743
|
+
this.st.dirs.add(p);
|
|
744
|
+
}
|
|
745
|
+
async deleteFile(path) {
|
|
746
|
+
const p = this.abs(path);
|
|
747
|
+
if (!await this.exists(p)) throw new Error(`File not found: ${path}`);
|
|
748
|
+
if (await this.isDirectory(p) && (await this.readDir(p)).length > 0) throw new Error(`Directory not empty: ${path}`);
|
|
749
|
+
this.st.writes.delete(p);
|
|
750
|
+
this.st.dirs.delete(p);
|
|
751
|
+
this.st.deletes.add(p);
|
|
752
|
+
}
|
|
753
|
+
// ---- checkpointing ----
|
|
754
|
+
clone(s) {
|
|
755
|
+
return { writes: new Map(s.writes), deletes: new Set(s.deletes), dirs: new Set(s.dirs) };
|
|
756
|
+
}
|
|
757
|
+
/** Capture the current overlay; returns a token to `rollback(token)` to. */
|
|
758
|
+
snapshot(label) {
|
|
759
|
+
const token = label ?? `snap-${++this.seq}`;
|
|
760
|
+
this.snapshots.set(token, this.clone(this.st));
|
|
761
|
+
return token;
|
|
762
|
+
}
|
|
763
|
+
/** Discard all changes since the named snapshot (or the most recent if omitted). */
|
|
764
|
+
rollback(token) {
|
|
765
|
+
const key = token ?? [...this.snapshots.keys()].pop();
|
|
766
|
+
if (key == null || !this.snapshots.has(key)) throw new Error(`No snapshot '${token ?? "(latest)"}'`);
|
|
767
|
+
this.st = this.clone(this.snapshots.get(key));
|
|
768
|
+
}
|
|
769
|
+
/** Paths changed vs the base since this overlay was created (added = not in base, modified = was). */
|
|
770
|
+
async diff() {
|
|
771
|
+
const added = [], modified = [];
|
|
772
|
+
for (const p of this.st.writes.keys()) (await this.base.exists(p) ? modified : added).push(p);
|
|
773
|
+
return { added: added.sort(), modified: modified.sort(), deleted: [...this.st.deletes].sort() };
|
|
774
|
+
}
|
|
775
|
+
/** Flush the overlay into the base (writes, mkdirs, deletes), then clear the overlay. */
|
|
776
|
+
async commit() {
|
|
777
|
+
for (const d of this.st.dirs) if (!await this.base.exists(d)) await this.base.createDir(d).catch(() => {
|
|
778
|
+
});
|
|
779
|
+
for (const [p, c] of this.st.writes) await this.base.writeFile(p, c);
|
|
780
|
+
for (const p of this.st.deletes) if (await this.base.exists(p)) await this.base.deleteFile(p).catch(() => {
|
|
781
|
+
});
|
|
782
|
+
this.st = { writes: /* @__PURE__ */ new Map(), deletes: /* @__PURE__ */ new Set(), dirs: /* @__PURE__ */ new Set() };
|
|
783
|
+
this.snapshots.clear();
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
asCheckpointable = (fs) => typeof fs?.snapshot === "function" && typeof fs?.rollback === "function" ? fs : null;
|
|
787
|
+
NEEDS_OVERLAY = "Error: checkpointing requires an OverlayFilesystem (run with `checkpoints: true` over one).";
|
|
788
|
+
checkpointTool = {
|
|
789
|
+
name: "Checkpoint",
|
|
790
|
+
description: "Snapshot the filesystem before a risky or exploratory multi-step change, so you can undo it. Returns a token to pass to Rollback if you need to revert.",
|
|
791
|
+
parameters: { type: "object", properties: { label: { type: "string", description: "optional name for the checkpoint" } } },
|
|
792
|
+
async run({ label }, ctx) {
|
|
793
|
+
const fs = asCheckpointable(ctx.fs);
|
|
794
|
+
if (!fs) return NEEDS_OVERLAY;
|
|
795
|
+
return `Checkpoint '${fs.snapshot(label ? String(label) : void 0)}' created \u2014 call Rollback with it to undo everything since.`;
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
rollbackTool = {
|
|
799
|
+
name: "Rollback",
|
|
800
|
+
description: "Undo ALL filesystem changes made since a Checkpoint. Pass the token from Checkpoint, or omit to revert to the most recent one. Use when an approach went wrong.",
|
|
801
|
+
parameters: { type: "object", properties: { to: { type: "string", description: "checkpoint token (omit for most recent)" } } },
|
|
802
|
+
async run({ to }, ctx) {
|
|
803
|
+
const fs = asCheckpointable(ctx.fs);
|
|
804
|
+
if (!fs) return NEEDS_OVERLAY;
|
|
805
|
+
try {
|
|
806
|
+
fs.rollback(to ? String(to) : void 0);
|
|
807
|
+
return `Rolled back to checkpoint '${to ?? "(latest)"}'.`;
|
|
808
|
+
} catch (e) {
|
|
809
|
+
return `Error: ${e?.message ?? e}`;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
checkpointTools = () => [checkpointTool, rollbackTool];
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// src/tools.ts
|
|
818
|
+
import { CommandExecutor, registerHeadlessCommands } from "@livx.cc/wcli/core";
|
|
819
|
+
function makeContext(fs, host) {
|
|
820
|
+
const exec = new CommandExecutor(fs);
|
|
821
|
+
registerHeadlessCommands(exec);
|
|
822
|
+
return { fs, exec, readState: /* @__PURE__ */ new Map(), host, todos: [] };
|
|
823
|
+
}
|
|
824
|
+
function toWireTools(tools) {
|
|
825
|
+
return tools.map((t) => ({
|
|
826
|
+
type: "function",
|
|
827
|
+
function: { name: t.name, description: t.description, parameters: t.parameters }
|
|
828
|
+
}));
|
|
829
|
+
}
|
|
830
|
+
function truncateOutput(s, headLines = 80, tailLines = 20) {
|
|
831
|
+
const lines = s.split("\n");
|
|
832
|
+
if (lines.length <= headLines + tailLines + 1) return s;
|
|
833
|
+
const omitted = lines.length - headLines - tailLines;
|
|
834
|
+
return [...lines.slice(0, headLines), `\u2026 (${omitted} lines omitted \u2014 narrow the command to see more) \u2026`, ...lines.slice(-tailLines)].join("\n");
|
|
835
|
+
}
|
|
836
|
+
function startBashJob(command, ctx) {
|
|
837
|
+
const baseFs = ctx.fs;
|
|
838
|
+
const id = ctx.jobs.start(
|
|
839
|
+
async ({ signal }) => {
|
|
840
|
+
const overlay = new OverlayFilesystem(baseFs);
|
|
841
|
+
const exec = new CommandExecutor(overlay);
|
|
842
|
+
registerHeadlessCommands(exec);
|
|
843
|
+
const r = await exec.execute(command);
|
|
844
|
+
if (signal.aborted) return "[killed before commit]";
|
|
845
|
+
await overlay.commit();
|
|
846
|
+
const out = truncateOutput((r.output ?? "").replace(/\n+$/, ""));
|
|
847
|
+
return r.exitCode !== 0 ? `[exit ${r.exitCode}] ${(r.error ?? "").trim()}
|
|
848
|
+
${out}`.trim() : out || "(command succeeded, no output)";
|
|
849
|
+
},
|
|
850
|
+
{ kind: "bash", label: command.slice(0, 60) }
|
|
851
|
+
);
|
|
852
|
+
return `Started background job ${id} \u2014 poll with JobOutput({id:"${id}"}) / JobStatus, stop with JobKill.`;
|
|
853
|
+
}
|
|
854
|
+
function defaultTools() {
|
|
855
|
+
return [bashTool, readTool, editTool];
|
|
856
|
+
}
|
|
857
|
+
function toolRegistry() {
|
|
858
|
+
const all = [bashTool, readTool, editTool, grepTool, globTool, writeTool, multiEditTool, applyEditsTool, repoMapTool, reviewTool(), todoWriteTool, webFetchTool, webSearchTool];
|
|
859
|
+
return Object.fromEntries(all.map((t) => [t.name, t]));
|
|
860
|
+
}
|
|
861
|
+
function toolsByName(names) {
|
|
862
|
+
const reg = toolRegistry();
|
|
863
|
+
return names.map((n) => {
|
|
864
|
+
const t = reg[n];
|
|
865
|
+
if (!t) throw new Error(`unknown tool '${n}'. Known: ${Object.keys(reg).join(", ")}`);
|
|
866
|
+
return t;
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
var numberLines, bashTool, IMG_MIME, readTool, editTool;
|
|
870
|
+
var init_tools = __esm({
|
|
871
|
+
"src/tools.ts"() {
|
|
872
|
+
"use strict";
|
|
873
|
+
init_tools_structured();
|
|
874
|
+
init_todo();
|
|
875
|
+
init_tools_web();
|
|
876
|
+
init_OverlayFilesystem();
|
|
877
|
+
init_redact();
|
|
878
|
+
numberLines = (content, offset = 0, limit) => {
|
|
879
|
+
const lines = content.split("\n");
|
|
880
|
+
const start = Math.max(0, offset);
|
|
881
|
+
const end = limit != null ? start + limit : lines.length;
|
|
882
|
+
return lines.slice(start, end).map((l, i) => `${start + i + 1} ${l}`).join("\n");
|
|
883
|
+
};
|
|
884
|
+
bashTool = {
|
|
885
|
+
name: "bash",
|
|
886
|
+
description: "Run a shell command. Supports ls, cat, grep, find, head, tail, echo, mkdir, rm, mv, cp, wc, pipes (|), redirects (>, >>), and chaining (&&, ||, ;). Best for: running tests/builds, file operations (mkdir/mv/rm), and piped workflows. For searching file contents, prefer `Grep` (structured results, no re-parse). For finding files by name, prefer `Glob`.",
|
|
887
|
+
parameters: {
|
|
888
|
+
type: "object",
|
|
889
|
+
required: ["command"],
|
|
890
|
+
properties: {
|
|
891
|
+
command: { type: "string", description: "the command line to execute" },
|
|
892
|
+
background: { type: "boolean", description: "run detached over an isolated overlay (writes commit when it finishes); returns a job id to poll with JobOutput. Only worth it for slow work (remote VFS / long pipelines)." }
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
async run({ command, background }, ctx) {
|
|
896
|
+
if (background && ctx.jobs) return startBashJob(String(command ?? ""), ctx);
|
|
897
|
+
const r = await ctx.exec.execute(String(command ?? ""));
|
|
898
|
+
const out = truncateOutput((r.output ?? "").replace(/\n+$/, ""));
|
|
899
|
+
if (r.exitCode !== 0) {
|
|
900
|
+
const err = (r.error ?? "").trim();
|
|
901
|
+
return `[exit ${r.exitCode}]${err ? " " + err : ""}${out ? "\n" + out : ""}`;
|
|
902
|
+
}
|
|
903
|
+
return out || "(command succeeded, no output)";
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
IMG_MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
|
|
907
|
+
readTool = {
|
|
908
|
+
name: "Read",
|
|
909
|
+
description: "Read a file. Text files return 1-indexed numbered lines (with optional `offset`/`limit` and a re-Read pointer for partial reads). Image files (png/jpg/jpeg/gif/webp) return the picture itself so you can SEE it. Always Read a file before Editing it.",
|
|
910
|
+
parameters: {
|
|
911
|
+
type: "object",
|
|
912
|
+
required: ["path"],
|
|
913
|
+
properties: {
|
|
914
|
+
path: { type: "string" },
|
|
915
|
+
offset: { type: "number" },
|
|
916
|
+
limit: { type: "number" }
|
|
917
|
+
}
|
|
918
|
+
},
|
|
919
|
+
async run({ path, offset, limit }, ctx) {
|
|
920
|
+
const ext = String(path).toLowerCase().split(".").pop() ?? "";
|
|
921
|
+
if (IMG_MIME[ext]) {
|
|
922
|
+
const fs = ctx.fs;
|
|
923
|
+
if (typeof fs.readFileBytes !== "function") {
|
|
924
|
+
return `[${path} is an image, but this filesystem can't read binary \u2014 attach it as @${path} instead, or run on disk.]`;
|
|
925
|
+
}
|
|
926
|
+
const bytes = await fs.readFileBytes(path);
|
|
927
|
+
const b64 = Buffer.from(bytes).toString("base64");
|
|
928
|
+
return JSON.stringify({ dataUrl: `data:${IMG_MIME[ext]};base64,${b64}`, image: path });
|
|
929
|
+
}
|
|
930
|
+
const raw = await ctx.fs.readFile(path);
|
|
931
|
+
ctx.readState.set(ctx.fs.resolvePath(path), raw);
|
|
932
|
+
const content = CONFIG_FILE_RE.test(ctx.fs.resolvePath(path)) ? redactSecrets(raw) : raw;
|
|
933
|
+
const total = content === "" ? 0 : content.split("\n").length;
|
|
934
|
+
const start = Math.max(0, offset ?? 0);
|
|
935
|
+
const body = numberLines(content, start, limit);
|
|
936
|
+
const shownEnd = limit != null ? Math.min(start + limit, total) : total;
|
|
937
|
+
const shownCount = Math.max(0, shownEnd - start);
|
|
938
|
+
if (shownCount >= total) return body;
|
|
939
|
+
if (shownCount === 0) return `[no lines in range (offset ${start}${limit != null ? `, limit ${limit}` : ""}) \u2014 file has ${total} line(s)]`;
|
|
940
|
+
return `${body}
|
|
941
|
+
|
|
942
|
+
[lines ${start + 1}\u2013${shownEnd} of ${total} \xB7 re-Read with offset/limit for the rest]`;
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
editTool = {
|
|
946
|
+
name: "Edit",
|
|
947
|
+
description: "Replace an exact substring in a file. Requires a prior Read of the same file. `old_string` must occur exactly once \u2014 include surrounding context to disambiguate.",
|
|
948
|
+
parameters: {
|
|
949
|
+
type: "object",
|
|
950
|
+
required: ["path", "old_string", "new_string"],
|
|
951
|
+
properties: {
|
|
952
|
+
path: { type: "string" },
|
|
953
|
+
old_string: { type: "string" },
|
|
954
|
+
new_string: { type: "string" }
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
async run({ path, old_string, new_string }, ctx) {
|
|
958
|
+
const key = ctx.fs.resolvePath(path);
|
|
959
|
+
const snapshot = ctx.readState.get(key);
|
|
960
|
+
if (snapshot == null) throw new Error(`File has not been read yet: ${path}. Read it before editing.`);
|
|
961
|
+
const current = await ctx.fs.readFile(path);
|
|
962
|
+
if (current !== snapshot) throw new Error(`File ${path} changed since it was read (stale). Re-read before editing.`);
|
|
963
|
+
const count = old_string === "" ? 0 : current.split(old_string).length - 1;
|
|
964
|
+
if (count > 1) throw new Error(`old_string is not unique in ${path} (${count} matches). Provide more surrounding context.`);
|
|
965
|
+
let next, note = "";
|
|
966
|
+
if (count === 1) {
|
|
967
|
+
next = current.replace(old_string, () => new_string);
|
|
968
|
+
} else {
|
|
969
|
+
const fuzzy = fuzzyLineReplace(current, old_string, new_string);
|
|
970
|
+
if (fuzzy == null) throw new Error(`old_string not found in ${path}.`);
|
|
971
|
+
next = fuzzy;
|
|
972
|
+
note = " (whitespace-tolerant match)";
|
|
973
|
+
}
|
|
974
|
+
if (ctx.lint) {
|
|
975
|
+
const err = ctx.lint(path, next);
|
|
976
|
+
if (err) throw new Error(err);
|
|
977
|
+
}
|
|
978
|
+
await ctx.fs.writeFile(path, next);
|
|
979
|
+
ctx.readState.set(key, next);
|
|
980
|
+
return `Edited ${path}${note}`;
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
// src/NodeDiskFilesystem.ts
|
|
987
|
+
var NodeDiskFilesystem_exports = {};
|
|
988
|
+
__export(NodeDiskFilesystem_exports, {
|
|
989
|
+
NodeDiskFilesystem: () => NodeDiskFilesystem
|
|
990
|
+
});
|
|
991
|
+
import { promises as fsp } from "fs";
|
|
992
|
+
import * as np from "path";
|
|
993
|
+
import { PathResolver } from "@livx.cc/wcli/core";
|
|
994
|
+
var NodeDiskFilesystem;
|
|
995
|
+
var init_NodeDiskFilesystem = __esm({
|
|
996
|
+
"src/NodeDiskFilesystem.ts"() {
|
|
997
|
+
"use strict";
|
|
998
|
+
NodeDiskFilesystem = class {
|
|
999
|
+
constructor(baseDir, opts = {}) {
|
|
1000
|
+
this.baseDir = baseDir;
|
|
1001
|
+
this.opts = { denySymlinks: true, ...opts };
|
|
1002
|
+
}
|
|
1003
|
+
baseDir;
|
|
1004
|
+
cwd = "/";
|
|
1005
|
+
opts;
|
|
1006
|
+
/** Ensure the root dir exists. */
|
|
1007
|
+
async init() {
|
|
1008
|
+
await fsp.mkdir(this.baseDir, { recursive: true });
|
|
1009
|
+
}
|
|
1010
|
+
real(vpath) {
|
|
1011
|
+
return np.join(this.baseDir, "." + vpath);
|
|
1012
|
+
}
|
|
1013
|
+
/** Throw if any existing component of `real` is a symlink (escape vector). */
|
|
1014
|
+
async assertNoSymlink(real) {
|
|
1015
|
+
if (!this.opts.denySymlinks) return;
|
|
1016
|
+
const rel = np.relative(this.baseDir, real);
|
|
1017
|
+
if (rel === "" || rel.startsWith("..")) return;
|
|
1018
|
+
let cur = this.baseDir;
|
|
1019
|
+
for (const part of rel.split(np.sep)) {
|
|
1020
|
+
cur = np.join(cur, part);
|
|
1021
|
+
let st;
|
|
1022
|
+
try {
|
|
1023
|
+
st = await fsp.lstat(cur);
|
|
1024
|
+
} catch {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (st.isSymbolicLink()) throw new Error("File not found: symlink not permitted");
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
resolvePath(path, cwd) {
|
|
1031
|
+
return PathResolver.resolve(path, cwd || this.cwd);
|
|
1032
|
+
}
|
|
1033
|
+
getCwd() {
|
|
1034
|
+
return this.cwd;
|
|
1035
|
+
}
|
|
1036
|
+
setCwd(path) {
|
|
1037
|
+
this.cwd = PathResolver.normalize(path);
|
|
1038
|
+
}
|
|
1039
|
+
async readFile(path) {
|
|
1040
|
+
const r = this.real(this.resolvePath(path));
|
|
1041
|
+
try {
|
|
1042
|
+
await this.assertNoSymlink(r);
|
|
1043
|
+
const st = await fsp.stat(r);
|
|
1044
|
+
if (st.isDirectory()) throw new Error(`Not a file: ${path}`);
|
|
1045
|
+
return await fsp.readFile(r, "utf8");
|
|
1046
|
+
} catch (e) {
|
|
1047
|
+
if (e instanceof Error && /Not a file/.test(e.message)) throw e;
|
|
1048
|
+
throw new Error(`File not found: ${path}`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/** Read raw bytes (for binary files like images). Not on the base IFilesystem (which is utf8-only) — an
|
|
1052
|
+
* optional capability the Read tool duck-types to return image blocks. Same symlink/jail guards as readFile. */
|
|
1053
|
+
async readFileBytes(path) {
|
|
1054
|
+
const r = this.real(this.resolvePath(path));
|
|
1055
|
+
try {
|
|
1056
|
+
await this.assertNoSymlink(r);
|
|
1057
|
+
const st = await fsp.stat(r);
|
|
1058
|
+
if (st.isDirectory()) throw new Error(`Not a file: ${path}`);
|
|
1059
|
+
return await fsp.readFile(r);
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
if (e instanceof Error && /Not a file/.test(e.message)) throw e;
|
|
1062
|
+
throw new Error(`File not found: ${path}`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
async writeFile(path, content) {
|
|
1066
|
+
const r = this.real(this.resolvePath(path));
|
|
1067
|
+
await this.assertNoSymlink(r);
|
|
1068
|
+
const parent = np.dirname(r);
|
|
1069
|
+
try {
|
|
1070
|
+
if (!(await fsp.stat(parent)).isDirectory()) throw 0;
|
|
1071
|
+
} catch {
|
|
1072
|
+
throw new Error(`Parent directory does not exist: ${path}`);
|
|
1073
|
+
}
|
|
1074
|
+
await fsp.writeFile(r, content, "utf8");
|
|
1075
|
+
}
|
|
1076
|
+
async deleteFile(path) {
|
|
1077
|
+
const r = this.real(this.resolvePath(path));
|
|
1078
|
+
await this.assertNoSymlink(r);
|
|
1079
|
+
let st;
|
|
1080
|
+
try {
|
|
1081
|
+
st = await fsp.stat(r);
|
|
1082
|
+
} catch {
|
|
1083
|
+
throw new Error(`File not found: ${path}`);
|
|
1084
|
+
}
|
|
1085
|
+
if (st.isDirectory()) {
|
|
1086
|
+
if ((await fsp.readdir(r)).length > 0) throw new Error(`Directory not empty: ${path}`);
|
|
1087
|
+
await fsp.rmdir(r);
|
|
1088
|
+
} else {
|
|
1089
|
+
await fsp.unlink(r);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
async readDir(path) {
|
|
1093
|
+
const r = this.real(this.resolvePath(path));
|
|
1094
|
+
try {
|
|
1095
|
+
await this.assertNoSymlink(r);
|
|
1096
|
+
return await fsp.readdir(r);
|
|
1097
|
+
} catch {
|
|
1098
|
+
throw new Error(`Directory not found: ${path}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
async createDir(path) {
|
|
1102
|
+
if (await this.exists(path)) throw new Error(`File or directory already exists: ${path}`);
|
|
1103
|
+
const r = this.real(this.resolvePath(path));
|
|
1104
|
+
await this.assertNoSymlink(r);
|
|
1105
|
+
const parent = np.dirname(r);
|
|
1106
|
+
try {
|
|
1107
|
+
if (!(await fsp.stat(parent)).isDirectory()) throw 0;
|
|
1108
|
+
} catch {
|
|
1109
|
+
throw new Error(`Parent directory does not exist: ${path}`);
|
|
1110
|
+
}
|
|
1111
|
+
await fsp.mkdir(r);
|
|
1112
|
+
}
|
|
1113
|
+
async exists(path) {
|
|
1114
|
+
const r = this.real(this.resolvePath(path));
|
|
1115
|
+
try {
|
|
1116
|
+
await this.assertNoSymlink(r);
|
|
1117
|
+
await fsp.stat(r);
|
|
1118
|
+
return true;
|
|
1119
|
+
} catch {
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
async stat(path) {
|
|
1124
|
+
let s;
|
|
1125
|
+
const r = this.real(this.resolvePath(path));
|
|
1126
|
+
try {
|
|
1127
|
+
await this.assertNoSymlink(r);
|
|
1128
|
+
s = await fsp.stat(r);
|
|
1129
|
+
} catch {
|
|
1130
|
+
throw new Error(`File not found: ${path}`);
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
created: s.birthtime,
|
|
1134
|
+
modified: s.mtime,
|
|
1135
|
+
size: s.size,
|
|
1136
|
+
permissions: s.isDirectory() ? "drwxr-xr-x" : "-rw-r--r--",
|
|
1137
|
+
isExecutable: false
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
async isDirectory(path) {
|
|
1141
|
+
const r = this.real(this.resolvePath(path));
|
|
1142
|
+
try {
|
|
1143
|
+
await this.assertNoSymlink(r);
|
|
1144
|
+
return (await fsp.stat(r)).isDirectory();
|
|
1145
|
+
} catch {
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
async isFile(path) {
|
|
1150
|
+
const r = this.real(this.resolvePath(path));
|
|
1151
|
+
try {
|
|
1152
|
+
await this.assertNoSymlink(r);
|
|
1153
|
+
return (await fsp.stat(r)).isFile();
|
|
1154
|
+
} catch {
|
|
1155
|
+
return false;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
// src/JailedFilesystem.ts
|
|
1163
|
+
var JailedFilesystem_exports = {};
|
|
1164
|
+
__export(JailedFilesystem_exports, {
|
|
1165
|
+
DEFAULT_DENY: () => DEFAULT_DENY,
|
|
1166
|
+
JailOptions: () => JailOptions,
|
|
1167
|
+
JailedFilesystem: () => JailedFilesystem
|
|
1168
|
+
});
|
|
1169
|
+
var JailedFilesystem, DEFAULT_DENY, JailOptions;
|
|
1170
|
+
var init_JailedFilesystem = __esm({
|
|
1171
|
+
"src/JailedFilesystem.ts"() {
|
|
1172
|
+
"use strict";
|
|
1173
|
+
init_tools_structured();
|
|
1174
|
+
JailedFilesystem = class {
|
|
1175
|
+
constructor(inner, options) {
|
|
1176
|
+
this.inner = inner;
|
|
1177
|
+
this.options = { ...new JailOptions(), ...options };
|
|
1178
|
+
this.denyRe = this.options.deny.map((g) => globToRegExp(g, true));
|
|
1179
|
+
this.roRe = this.options.readonly.map((g) => globToRegExp(g, true));
|
|
1180
|
+
this.confineRe = this.options.confineTo?.map((g) => globToRegExp(g, true));
|
|
1181
|
+
}
|
|
1182
|
+
inner;
|
|
1183
|
+
options;
|
|
1184
|
+
denyRe;
|
|
1185
|
+
roRe;
|
|
1186
|
+
confineRe;
|
|
1187
|
+
abs(path) {
|
|
1188
|
+
return this.inner.resolvePath(path, this.inner.getCwd());
|
|
1189
|
+
}
|
|
1190
|
+
/** Throw if reading `path` is not permitted (denied or out of confinement). */
|
|
1191
|
+
guardRead(op, path) {
|
|
1192
|
+
const abs = this.abs(path);
|
|
1193
|
+
if (this.confineRe && !this.confineRe.some((r) => r.test(abs))) return this.violate(op, abs, "confine");
|
|
1194
|
+
if (this.denyRe.some((r) => r.test(abs))) return this.violate(op, abs, "deny");
|
|
1195
|
+
return abs;
|
|
1196
|
+
}
|
|
1197
|
+
/** Throw if writing/deleting `path` is not permitted (denied, readonly, or out of confinement). */
|
|
1198
|
+
guardWrite(op, path) {
|
|
1199
|
+
const abs = this.guardRead(op, path);
|
|
1200
|
+
if (this.roRe.some((r) => r.test(abs))) return this.violate(op, abs, "readonly");
|
|
1201
|
+
return abs;
|
|
1202
|
+
}
|
|
1203
|
+
violate(op, abs, rule) {
|
|
1204
|
+
this.note(op, abs, rule);
|
|
1205
|
+
if (rule === "deny" || rule === "confine") throw new Error(`File not found: ${abs}`);
|
|
1206
|
+
throw new Error(`Blocked by filesystem jail (${rule}): ${abs}`);
|
|
1207
|
+
}
|
|
1208
|
+
/** Fire the audit hook without throwing (for boolean probes that must still return false). */
|
|
1209
|
+
note(op, abs, rule) {
|
|
1210
|
+
this.options.onViolation?.({ op, path: abs, rule });
|
|
1211
|
+
}
|
|
1212
|
+
/** Is `abs` (already-resolved) visible? If not, fire the audit hook (op given) and return false. */
|
|
1213
|
+
visible(abs, op) {
|
|
1214
|
+
if (this.confineRe && !this.confineRe.some((r) => r.test(abs))) {
|
|
1215
|
+
if (op) this.note(op, abs, "confine");
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
if (this.denyRe.some((r) => r.test(abs))) {
|
|
1219
|
+
if (op) this.note(op, abs, "deny");
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
return true;
|
|
1223
|
+
}
|
|
1224
|
+
async readFile(path) {
|
|
1225
|
+
return this.inner.readFile(this.guardRead("readFile", path));
|
|
1226
|
+
}
|
|
1227
|
+
/** Forward the optional binary-read capability (if the inner fs has it), jailed like readFile. */
|
|
1228
|
+
async readFileBytes(path) {
|
|
1229
|
+
const inner = this.inner;
|
|
1230
|
+
if (typeof inner.readFileBytes !== "function") throw new Error("binary read not supported by this filesystem");
|
|
1231
|
+
return inner.readFileBytes(this.guardRead("readFileBytes", path));
|
|
1232
|
+
}
|
|
1233
|
+
async writeFile(path, content) {
|
|
1234
|
+
return this.inner.writeFile(this.guardWrite("writeFile", path), content);
|
|
1235
|
+
}
|
|
1236
|
+
async deleteFile(path) {
|
|
1237
|
+
return this.inner.deleteFile(this.guardWrite("deleteFile", path));
|
|
1238
|
+
}
|
|
1239
|
+
async createDir(path) {
|
|
1240
|
+
return this.inner.createDir(this.guardWrite("createDir", path));
|
|
1241
|
+
}
|
|
1242
|
+
async stat(path) {
|
|
1243
|
+
return this.inner.stat(this.guardRead("stat", path));
|
|
1244
|
+
}
|
|
1245
|
+
// Boolean probes stay invisible (return false on a denied path) BUT still fire the
|
|
1246
|
+
// audit hook — otherwise an agent could enumerate secrets via exists() without
|
|
1247
|
+
// ever tripping the circuit breaker.
|
|
1248
|
+
async exists(path) {
|
|
1249
|
+
const abs = this.abs(path);
|
|
1250
|
+
return this.visible(abs, "exists") ? this.inner.exists(abs) : false;
|
|
1251
|
+
}
|
|
1252
|
+
async isDirectory(path) {
|
|
1253
|
+
const abs = this.abs(path);
|
|
1254
|
+
return this.visible(abs, "isDirectory") ? this.inner.isDirectory(abs) : false;
|
|
1255
|
+
}
|
|
1256
|
+
async isFile(path) {
|
|
1257
|
+
const abs = this.abs(path);
|
|
1258
|
+
return this.visible(abs, "isFile") ? this.inner.isFile(abs) : false;
|
|
1259
|
+
}
|
|
1260
|
+
/** List a directory, filtering out entries the policy hides. */
|
|
1261
|
+
async readDir(path) {
|
|
1262
|
+
const abs = this.guardRead("readDir", path);
|
|
1263
|
+
const entries = await this.inner.readDir(abs);
|
|
1264
|
+
return entries.filter((name) => this.visible(abs === "/" ? `/${name}` : `${abs}/${name}`));
|
|
1265
|
+
}
|
|
1266
|
+
resolvePath(path, cwd) {
|
|
1267
|
+
return this.inner.resolvePath(path, cwd ?? this.inner.getCwd());
|
|
1268
|
+
}
|
|
1269
|
+
getCwd() {
|
|
1270
|
+
return this.inner.getCwd();
|
|
1271
|
+
}
|
|
1272
|
+
/** Guard cwd too: the jail must never be able to "stand" on a denied/out-of-confine dir. */
|
|
1273
|
+
setCwd(path) {
|
|
1274
|
+
const abs = this.abs(path);
|
|
1275
|
+
if (!this.visible(abs, "setCwd")) throw new Error(`File not found: ${abs}`);
|
|
1276
|
+
this.inner.setCwd(abs);
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
DEFAULT_DENY = [
|
|
1280
|
+
// env / config secrets
|
|
1281
|
+
"**/.env",
|
|
1282
|
+
"**/.env.*",
|
|
1283
|
+
"**/.npmrc",
|
|
1284
|
+
"**/.netrc",
|
|
1285
|
+
// private keys & certs
|
|
1286
|
+
"**/*.pem",
|
|
1287
|
+
"**/*.key",
|
|
1288
|
+
"**/*.crt",
|
|
1289
|
+
"**/*.cert",
|
|
1290
|
+
"**/*.p12",
|
|
1291
|
+
"**/*.pfx",
|
|
1292
|
+
"**/id_rsa",
|
|
1293
|
+
"**/id_rsa.*",
|
|
1294
|
+
"**/id_ed25519",
|
|
1295
|
+
"**/id_ed25519.*",
|
|
1296
|
+
"**/id_dsa",
|
|
1297
|
+
"**/id_dsa.*",
|
|
1298
|
+
"**/id_ecdsa",
|
|
1299
|
+
"**/id_ecdsa.*",
|
|
1300
|
+
// credential stores (dir AND contents)
|
|
1301
|
+
"**/.ssh",
|
|
1302
|
+
"**/.ssh/**",
|
|
1303
|
+
"**/.aws",
|
|
1304
|
+
"**/.aws/**",
|
|
1305
|
+
"**/.docker",
|
|
1306
|
+
"**/.docker/**",
|
|
1307
|
+
"**/.dockercfg",
|
|
1308
|
+
"**/.kube",
|
|
1309
|
+
"**/.kube/**",
|
|
1310
|
+
"**/.kubeconfig",
|
|
1311
|
+
"**/credentials",
|
|
1312
|
+
"**/.credentials",
|
|
1313
|
+
"**/secrets.json",
|
|
1314
|
+
// VCS internals + git config (can carry tokens / remote creds)
|
|
1315
|
+
"**/.git",
|
|
1316
|
+
"**/.git/**",
|
|
1317
|
+
"**/.gitconfig",
|
|
1318
|
+
"**/.gitmodules"
|
|
1319
|
+
];
|
|
1320
|
+
JailOptions = class {
|
|
1321
|
+
/** Globs that are invisible and unwritable (secrets). */
|
|
1322
|
+
deny = [...DEFAULT_DENY];
|
|
1323
|
+
/** Globs readable but not writable/deletable (the pinned constitution). */
|
|
1324
|
+
readonly = [];
|
|
1325
|
+
/** If set, only paths matching these globs are accessible at all. */
|
|
1326
|
+
confineTo;
|
|
1327
|
+
/** Audit hook fired on every blocked operation (drives the circuit breaker). */
|
|
1328
|
+
onViolation;
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
// src/tools.shell.ts
|
|
1334
|
+
var tools_shell_exports = {};
|
|
1335
|
+
__export(tools_shell_exports, {
|
|
1336
|
+
ShellJobRegistry: () => ShellJobRegistry,
|
|
1337
|
+
makeRealShellTool: () => makeRealShellTool,
|
|
1338
|
+
makeShellJobTools: () => makeShellJobTools
|
|
1339
|
+
});
|
|
1340
|
+
function childEnv(opts) {
|
|
1341
|
+
const base = {};
|
|
1342
|
+
const redact = opts.redactEnv !== false;
|
|
1343
|
+
for (const [k, v] of Object.entries(process.env)) if (!(redact && SECRET_ENV_RE.test(k))) base[k] = v;
|
|
1344
|
+
return { ...base, ...opts.env };
|
|
1345
|
+
}
|
|
1346
|
+
async function nodeSpawn() {
|
|
1347
|
+
if (!_spawn) _spawn = (await import("child_process")).spawn;
|
|
1348
|
+
return _spawn;
|
|
1349
|
+
}
|
|
1350
|
+
function makeRealShellTool(options) {
|
|
1351
|
+
const timeoutMs = options.timeoutMs ?? 12e4;
|
|
1352
|
+
return {
|
|
1353
|
+
name: "Shell",
|
|
1354
|
+
description: "Run a shell command via /bin/sh in the working directory. Executes any installed binary \u2014 ls, cat, grep, git, bun, node, curl, scripts, etc. Returns combined stdout+stderr; non-zero exits are prefixed `[exit N]`. Set `background:true` for long-running processes (servers, watchers) \u2014 returns a job id immediately; poll with ShellOutput, stop with ShellKill.",
|
|
1355
|
+
parameters: {
|
|
1356
|
+
type: "object",
|
|
1357
|
+
required: ["command"],
|
|
1358
|
+
properties: {
|
|
1359
|
+
command: { type: "string", description: "the shell command line to execute" },
|
|
1360
|
+
background: { type: "boolean", description: "run detached and return a job id immediately (for servers/watchers/long builds)" }
|
|
1361
|
+
}
|
|
1362
|
+
},
|
|
1363
|
+
async run({ command, background }, ctx) {
|
|
1364
|
+
const cmd = String(command ?? "");
|
|
1365
|
+
if (!cmd.trim()) return "[exit 1] empty command";
|
|
1366
|
+
if (background) {
|
|
1367
|
+
if (!options.registry) return "Error: background execution is not enabled in this host (no job registry).";
|
|
1368
|
+
const id = await options.registry.start(cmd);
|
|
1369
|
+
return `Started background job ${id}. Poll output with ShellOutput({id:"${id}"}), check ShellStatus({id:"${id}"}), stop with ShellKill({id:"${id}"}).`;
|
|
1370
|
+
}
|
|
1371
|
+
const spawn = options.spawn ?? await nodeSpawn();
|
|
1372
|
+
const ctl = new AbortController();
|
|
1373
|
+
const onAbort = () => ctl.abort();
|
|
1374
|
+
if (ctx.signal) {
|
|
1375
|
+
if (ctx.signal.aborted) ctl.abort();
|
|
1376
|
+
else ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
1377
|
+
}
|
|
1378
|
+
let timedOut = false;
|
|
1379
|
+
const timer = setTimeout(() => {
|
|
1380
|
+
timedOut = true;
|
|
1381
|
+
ctl.abort();
|
|
1382
|
+
}, timeoutMs);
|
|
1383
|
+
try {
|
|
1384
|
+
return await new Promise((resolve) => {
|
|
1385
|
+
let out = "";
|
|
1386
|
+
let settled = false;
|
|
1387
|
+
const finish = (s) => {
|
|
1388
|
+
if (settled) return;
|
|
1389
|
+
settled = true;
|
|
1390
|
+
resolve(s);
|
|
1391
|
+
};
|
|
1392
|
+
let proc;
|
|
1393
|
+
try {
|
|
1394
|
+
proc = spawn("/bin/sh", ["-c", cmd], { cwd: options.cwd, env: childEnv(options), signal: ctl.signal });
|
|
1395
|
+
} catch (e) {
|
|
1396
|
+
return finish(`[exit 1] failed to spawn shell: ${e?.message ?? e}`);
|
|
1397
|
+
}
|
|
1398
|
+
const collect = (chunk) => {
|
|
1399
|
+
out += typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
|
|
1400
|
+
};
|
|
1401
|
+
proc.stdout?.on("data", collect);
|
|
1402
|
+
proc.stderr?.on("data", collect);
|
|
1403
|
+
proc.on("error", (err) => {
|
|
1404
|
+
if (err?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
|
|
1405
|
+
log4.debug("shell spawn error", err);
|
|
1406
|
+
finish(`[exit 1] ${err?.message ?? err}${out ? "\n" + clean(out) : ""}`);
|
|
1407
|
+
});
|
|
1408
|
+
proc.on("close", (code) => {
|
|
1409
|
+
if (ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
|
|
1410
|
+
const body = clean(out);
|
|
1411
|
+
if (code && code !== 0) return finish(`[exit ${code}]${body ? "\n" + body : ""}`);
|
|
1412
|
+
finish(body || "(command succeeded, no output)");
|
|
1413
|
+
});
|
|
1414
|
+
});
|
|
1415
|
+
} finally {
|
|
1416
|
+
clearTimeout(timer);
|
|
1417
|
+
ctx.signal?.removeEventListener("abort", onAbort);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
function reasonFor(timedOut, timeoutMs, body) {
|
|
1423
|
+
const head = timedOut ? `[exit 124] timed out after ${timeoutMs}ms (killed)` : "[exit 130] cancelled (killed)";
|
|
1424
|
+
return body ? `${head}
|
|
1425
|
+
${body}` : head;
|
|
1426
|
+
}
|
|
1427
|
+
function makeShellJobTools(registry) {
|
|
1428
|
+
const idParam = { type: "object", properties: { id: { type: "string", description: "the job id from Shell({background:true})" } } };
|
|
1429
|
+
return [
|
|
1430
|
+
{
|
|
1431
|
+
name: "ShellOutput",
|
|
1432
|
+
description: "Read the accumulated output (tail) of a background Shell job by id.",
|
|
1433
|
+
parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
|
|
1434
|
+
async run({ id }) {
|
|
1435
|
+
const out = registry.output(String(id));
|
|
1436
|
+
if (out == null) return NO_JOB2(String(id));
|
|
1437
|
+
const st = registry.status(String(id));
|
|
1438
|
+
return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}]
|
|
1439
|
+
${clean(out) || "(no output yet)"}`;
|
|
1440
|
+
}
|
|
1441
|
+
},
|
|
1442
|
+
{
|
|
1443
|
+
name: "ShellStatus",
|
|
1444
|
+
description: "Status of a background Shell job (running/exited/killed + exit code). Omit `id` to list all jobs.",
|
|
1445
|
+
parameters: idParam,
|
|
1446
|
+
async run({ id }) {
|
|
1447
|
+
if (!id) {
|
|
1448
|
+
const jobs = registry.list();
|
|
1449
|
+
return jobs.length ? jobs.map((j) => `${j.id} ${j.status} ${j.command}`).join("\n") : "(no background jobs)";
|
|
1450
|
+
}
|
|
1451
|
+
const st = registry.status(String(id));
|
|
1452
|
+
return st ? `${st.status}${st.exitCode != null ? ` (exit ${st.exitCode})` : ""} \xB7 ${st.bytes} byte(s) buffered` : NO_JOB2(String(id));
|
|
1453
|
+
}
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
name: "ShellKill",
|
|
1457
|
+
description: "Stop a running background Shell job by id (SIGTERM).",
|
|
1458
|
+
parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
|
|
1459
|
+
async run({ id }) {
|
|
1460
|
+
return registry.kill(String(id)) ? `Killed job ${id}.` : NO_JOB2(String(id));
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
];
|
|
1464
|
+
}
|
|
1465
|
+
var log4, clean, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
|
|
1466
|
+
var init_tools_shell = __esm({
|
|
1467
|
+
"src/tools.shell.ts"() {
|
|
1468
|
+
"use strict";
|
|
1469
|
+
init_tools();
|
|
1470
|
+
init_redact();
|
|
1471
|
+
init_logging();
|
|
1472
|
+
log4 = forComponent("shell");
|
|
1473
|
+
clean = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
|
|
1474
|
+
SECRET_ENV_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$)/i;
|
|
1475
|
+
ShellJobRegistry = class {
|
|
1476
|
+
constructor(cfg) {
|
|
1477
|
+
this.cfg = cfg;
|
|
1478
|
+
if (cfg.killOnExit && typeof process !== "undefined") process.once("exit", () => this.killAll());
|
|
1479
|
+
}
|
|
1480
|
+
cfg;
|
|
1481
|
+
jobs = /* @__PURE__ */ new Map();
|
|
1482
|
+
seq = 0;
|
|
1483
|
+
async start(command) {
|
|
1484
|
+
const id = `job-${++this.seq}`;
|
|
1485
|
+
const max = this.cfg.maxBuffer ?? 256 * 1024;
|
|
1486
|
+
const job = { command, buf: "", status: "running" };
|
|
1487
|
+
const append = (chunk) => {
|
|
1488
|
+
const s = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
|
|
1489
|
+
job.buf = (job.buf + s).slice(-max);
|
|
1490
|
+
};
|
|
1491
|
+
try {
|
|
1492
|
+
const spawn = this.cfg.spawn ?? await nodeSpawn();
|
|
1493
|
+
const proc = spawn("/bin/sh", ["-c", command], { cwd: this.cfg.cwd, env: childEnv(this.cfg) });
|
|
1494
|
+
job.proc = proc;
|
|
1495
|
+
proc.stdout?.on("data", append);
|
|
1496
|
+
proc.stderr?.on("data", append);
|
|
1497
|
+
proc.on("error", (err) => {
|
|
1498
|
+
if (job.status === "running") {
|
|
1499
|
+
job.status = "error";
|
|
1500
|
+
append(`
|
|
1501
|
+
[error] ${err?.message ?? err}`);
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
proc.on("close", (code) => {
|
|
1505
|
+
if (job.status === "running") {
|
|
1506
|
+
job.status = "exited";
|
|
1507
|
+
job.exitCode = code ?? void 0;
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
} catch (e) {
|
|
1511
|
+
job.status = "error";
|
|
1512
|
+
job.buf = `failed to spawn: ${e?.message ?? e}`;
|
|
1513
|
+
}
|
|
1514
|
+
this.jobs.set(id, job);
|
|
1515
|
+
return id;
|
|
1516
|
+
}
|
|
1517
|
+
/** Current tail output for a job (null = no such job). */
|
|
1518
|
+
output(id) {
|
|
1519
|
+
return this.jobs.get(id)?.buf ?? (this.jobs.has(id) ? "" : null);
|
|
1520
|
+
}
|
|
1521
|
+
status(id) {
|
|
1522
|
+
const j = this.jobs.get(id);
|
|
1523
|
+
return j ? { status: j.status, exitCode: j.exitCode, bytes: j.buf.length } : null;
|
|
1524
|
+
}
|
|
1525
|
+
list() {
|
|
1526
|
+
return [...this.jobs].map(([id, j]) => ({ id, command: j.command, status: j.status }));
|
|
1527
|
+
}
|
|
1528
|
+
kill(id) {
|
|
1529
|
+
const j = this.jobs.get(id);
|
|
1530
|
+
if (!j) return false;
|
|
1531
|
+
if (j.status === "running") {
|
|
1532
|
+
try {
|
|
1533
|
+
j.proc?.kill("SIGTERM");
|
|
1534
|
+
} catch {
|
|
1535
|
+
}
|
|
1536
|
+
j.status = "killed";
|
|
1537
|
+
}
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
killAll() {
|
|
1541
|
+
for (const id of this.jobs.keys()) this.kill(id);
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
NO_JOB2 = (id) => `Error: no background job '${id}'. Use ShellStatus with no id to list jobs, or start one with Shell({background:true}).`;
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
// src/llm.ts
|
|
1549
|
+
function contentText(content) {
|
|
1550
|
+
if (content == null) return "";
|
|
1551
|
+
if (typeof content === "string") return content;
|
|
1552
|
+
return content.map((p) => p.type === "text" ? p.text ?? "" : "[image]").join(p_sep(content));
|
|
1553
|
+
}
|
|
1554
|
+
var p_sep = (parts) => parts.length > 1 ? "\n" : "";
|
|
1555
|
+
function imagePart(url) {
|
|
1556
|
+
return { type: "image_url", image_url: { url } };
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// src/Agent.ts
|
|
1560
|
+
init_tools();
|
|
1561
|
+
|
|
1562
|
+
// src/tools.jobs.ts
|
|
1563
|
+
var SandboxJobRegistry = class {
|
|
1564
|
+
constructor(maxBuffer = 256 * 1024) {
|
|
1565
|
+
this.maxBuffer = maxBuffer;
|
|
1566
|
+
}
|
|
1567
|
+
maxBuffer;
|
|
1568
|
+
jobs = /* @__PURE__ */ new Map();
|
|
1569
|
+
seq = 0;
|
|
1570
|
+
/** Start `fn` in the background and return a job id immediately (does not await completion). */
|
|
1571
|
+
start(fn, opts = {}) {
|
|
1572
|
+
const id = `job-${++this.seq}`;
|
|
1573
|
+
const ctl = new AbortController();
|
|
1574
|
+
const onChunk = (s) => {
|
|
1575
|
+
job.buf = (job.buf + s).slice(-this.maxBuffer);
|
|
1576
|
+
};
|
|
1577
|
+
const job = { kind: opts.kind ?? "task", label: opts.label ?? "", status: "running", buf: "", ctl, promise: Promise.resolve() };
|
|
1578
|
+
job.promise = fn({ signal: ctl.signal, onChunk }).then(
|
|
1579
|
+
(res) => {
|
|
1580
|
+
if (job.status === "running") {
|
|
1581
|
+
job.status = "done";
|
|
1582
|
+
if (res) onChunk(res);
|
|
1583
|
+
}
|
|
1584
|
+
},
|
|
1585
|
+
(e) => {
|
|
1586
|
+
if (job.status === "running") {
|
|
1587
|
+
job.status = "error";
|
|
1588
|
+
onChunk(`
|
|
1589
|
+
[error] ${e?.message ?? e}`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
);
|
|
1593
|
+
this.jobs.set(id, job);
|
|
1594
|
+
return id;
|
|
1595
|
+
}
|
|
1596
|
+
/** Tail output for a job (null = no such job, '' = job exists but no output yet). */
|
|
1597
|
+
output(id) {
|
|
1598
|
+
return this.jobs.get(id)?.buf ?? (this.jobs.has(id) ? "" : null);
|
|
1599
|
+
}
|
|
1600
|
+
status(id) {
|
|
1601
|
+
const j = this.jobs.get(id);
|
|
1602
|
+
return j ? { status: j.status, bytes: j.buf.length } : null;
|
|
1603
|
+
}
|
|
1604
|
+
list() {
|
|
1605
|
+
return [...this.jobs].map(([id, j]) => ({ id, kind: j.kind, label: j.label, status: j.status }));
|
|
1606
|
+
}
|
|
1607
|
+
/** Signal cancel (the producer decides what abort means — e.g. skip the overlay commit). */
|
|
1608
|
+
kill(id) {
|
|
1609
|
+
const j = this.jobs.get(id);
|
|
1610
|
+
if (!j) return false;
|
|
1611
|
+
if (j.status === "running") {
|
|
1612
|
+
j.ctl.abort();
|
|
1613
|
+
j.status = "killed";
|
|
1614
|
+
}
|
|
1615
|
+
return true;
|
|
1616
|
+
}
|
|
1617
|
+
killAll() {
|
|
1618
|
+
for (const id of this.jobs.keys()) this.kill(id);
|
|
1619
|
+
}
|
|
1620
|
+
/** Await every still-running job. Called at turn end so a job's committed writes aren't lost from view
|
|
1621
|
+
* just because the model didn't poll it. Returns the ids that were still running when drain began. */
|
|
1622
|
+
async drain() {
|
|
1623
|
+
const pending = [...this.jobs].filter(([, j]) => j.status === "running").map(([id]) => id);
|
|
1624
|
+
await Promise.all([...this.jobs.values()].map((j) => j.promise));
|
|
1625
|
+
return pending;
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
var NO_JOB = (id) => `Error: no background job '${id}'. Use JobStatus with no id to list jobs, or start one with bash({background:true}).`;
|
|
1629
|
+
function makeJobTools(registry) {
|
|
1630
|
+
return [
|
|
1631
|
+
{
|
|
1632
|
+
name: "JobOutput",
|
|
1633
|
+
description: "Read the accumulated output (tail) of a background job by id (from bash({background:true})).",
|
|
1634
|
+
parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
|
|
1635
|
+
async run({ id }) {
|
|
1636
|
+
const out = registry.output(String(id));
|
|
1637
|
+
if (out == null) return NO_JOB(String(id));
|
|
1638
|
+
const st = registry.status(String(id));
|
|
1639
|
+
return `[${st.status}]
|
|
1640
|
+
${out.replace(/\n+$/, "") || "(no output yet)"}`;
|
|
1641
|
+
}
|
|
1642
|
+
},
|
|
1643
|
+
{
|
|
1644
|
+
name: "JobStatus",
|
|
1645
|
+
description: "Status of a background job (running/done/killed/error). Omit `id` to list all jobs.",
|
|
1646
|
+
parameters: { type: "object", properties: { id: { type: "string" } } },
|
|
1647
|
+
async run({ id }) {
|
|
1648
|
+
if (!id) {
|
|
1649
|
+
const jobs = registry.list();
|
|
1650
|
+
return jobs.length ? jobs.map((j) => `${j.id} ${j.status} ${j.kind}${j.label ? " " + j.label : ""}`).join("\n") : "(no background jobs)";
|
|
1651
|
+
}
|
|
1652
|
+
const st = registry.status(String(id));
|
|
1653
|
+
return st ? `${st.status} \xB7 ${st.bytes} byte(s) buffered` : NO_JOB(String(id));
|
|
1654
|
+
}
|
|
1655
|
+
},
|
|
1656
|
+
{
|
|
1657
|
+
name: "JobKill",
|
|
1658
|
+
description: "Stop a running background job by id (the job is cancelled; its overlay writes are NOT committed).",
|
|
1659
|
+
parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
|
|
1660
|
+
async run({ id }) {
|
|
1661
|
+
return registry.kill(String(id)) ? `Killed job ${id}.` : NO_JOB(String(id));
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
];
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// src/host.ts
|
|
1668
|
+
var askUserQuestionTool = {
|
|
1669
|
+
name: "AskUserQuestion",
|
|
1670
|
+
description: "Ask the user a multiple-choice question when a decision is genuinely theirs to make (ambiguous requirement, a fork you cannot resolve from context). Returns their selection.",
|
|
1671
|
+
parameters: {
|
|
1672
|
+
type: "object",
|
|
1673
|
+
required: ["question", "options"],
|
|
1674
|
+
properties: {
|
|
1675
|
+
question: { type: "string" },
|
|
1676
|
+
header: { type: "string", description: "short label for the question" },
|
|
1677
|
+
options: {
|
|
1678
|
+
type: "array",
|
|
1679
|
+
items: { type: "object", required: ["label"], properties: { label: { type: "string" }, description: { type: "string" } } }
|
|
1680
|
+
},
|
|
1681
|
+
multiSelect: { type: "boolean" }
|
|
1682
|
+
}
|
|
1683
|
+
},
|
|
1684
|
+
async run(args, ctx) {
|
|
1685
|
+
if (!ctx.host?.ask) {
|
|
1686
|
+
const fallback = args.options?.[0]?.label ?? "";
|
|
1687
|
+
return `No interactive user is available \u2014 proceed using your best judgment.${fallback ? ` (Closest default: "${fallback}")` : ""}`;
|
|
1688
|
+
}
|
|
1689
|
+
return ctx.host.ask(args);
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
var ScriptedHostBridge = class {
|
|
1693
|
+
constructor(answers = [], confirmDefault = true) {
|
|
1694
|
+
this.answers = answers;
|
|
1695
|
+
this.confirmDefault = confirmDefault;
|
|
1696
|
+
}
|
|
1697
|
+
answers;
|
|
1698
|
+
confirmDefault;
|
|
1699
|
+
asked = [];
|
|
1700
|
+
confirms = [];
|
|
1701
|
+
events = [];
|
|
1702
|
+
async ask(q) {
|
|
1703
|
+
this.asked.push(q);
|
|
1704
|
+
return this.answers.shift() ?? q.options[0]?.label ?? "";
|
|
1705
|
+
}
|
|
1706
|
+
async confirm(prompt) {
|
|
1707
|
+
this.confirms.push(prompt);
|
|
1708
|
+
return this.confirmDefault;
|
|
1709
|
+
}
|
|
1710
|
+
notify(event) {
|
|
1711
|
+
this.events.push(event);
|
|
1712
|
+
}
|
|
1713
|
+
};
|
|
1714
|
+
var ConsoleHostBridge = class {
|
|
1715
|
+
async ask(q) {
|
|
1716
|
+
const rl = await import("readline/promises");
|
|
1717
|
+
const io = rl.createInterface({ input: process.stdin, output: process.stdout });
|
|
1718
|
+
try {
|
|
1719
|
+
const lines = [q.header ? `
|
|
1720
|
+
[${q.header}] ${q.question}` : `
|
|
1721
|
+
${q.question}`];
|
|
1722
|
+
q.options.forEach((o, i) => lines.push(` ${i + 1}) ${o.label}${o.description ? " \u2014 " + o.description : ""}`));
|
|
1723
|
+
const ans = await io.question(lines.join("\n") + "\n> ");
|
|
1724
|
+
const n = Number(ans.trim());
|
|
1725
|
+
return Number.isInteger(n) && q.options[n - 1] ? q.options[n - 1].label : ans.trim();
|
|
1726
|
+
} finally {
|
|
1727
|
+
io.close();
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
async confirm(prompt) {
|
|
1731
|
+
const rl = await import("readline/promises");
|
|
1732
|
+
const io = rl.createInterface({ input: process.stdin, output: process.stdout });
|
|
1733
|
+
try {
|
|
1734
|
+
const ans = await io.question(`
|
|
1735
|
+
${prompt} [y/N] `);
|
|
1736
|
+
return /^y(es)?$/i.test(ans.trim());
|
|
1737
|
+
} finally {
|
|
1738
|
+
io.close();
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
notify(event) {
|
|
1742
|
+
console.error(`[${event.kind}] ${event.message}`);
|
|
1743
|
+
}
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
// src/relevance.ts
|
|
1747
|
+
var STOP = /* @__PURE__ */ new Set([
|
|
1748
|
+
"the",
|
|
1749
|
+
"and",
|
|
1750
|
+
"for",
|
|
1751
|
+
"with",
|
|
1752
|
+
"that",
|
|
1753
|
+
"this",
|
|
1754
|
+
"from",
|
|
1755
|
+
"into",
|
|
1756
|
+
"your",
|
|
1757
|
+
"you",
|
|
1758
|
+
"are",
|
|
1759
|
+
"was",
|
|
1760
|
+
"will",
|
|
1761
|
+
"use",
|
|
1762
|
+
"using",
|
|
1763
|
+
"run",
|
|
1764
|
+
"add",
|
|
1765
|
+
"fix",
|
|
1766
|
+
"make",
|
|
1767
|
+
"file",
|
|
1768
|
+
"files",
|
|
1769
|
+
"code",
|
|
1770
|
+
"please",
|
|
1771
|
+
"need",
|
|
1772
|
+
"want",
|
|
1773
|
+
"should",
|
|
1774
|
+
"all"
|
|
1775
|
+
]);
|
|
1776
|
+
function tokenize(s) {
|
|
1777
|
+
const out = /* @__PURE__ */ new Set();
|
|
1778
|
+
for (const w of String(s ?? "").toLowerCase().match(/[a-z0-9]+/g) ?? []) {
|
|
1779
|
+
if (w.length >= 3 && !STOP.has(w)) out.add(w);
|
|
1780
|
+
}
|
|
1781
|
+
return out;
|
|
1782
|
+
}
|
|
1783
|
+
function idfWeights(corpus) {
|
|
1784
|
+
const N = corpus.length;
|
|
1785
|
+
if (N === 0) return /* @__PURE__ */ new Map();
|
|
1786
|
+
const df = /* @__PURE__ */ new Map();
|
|
1787
|
+
for (const doc of corpus) for (const t of tokenize(doc)) df.set(t, (df.get(t) ?? 0) + 1);
|
|
1788
|
+
const idf = /* @__PURE__ */ new Map();
|
|
1789
|
+
for (const [t, n] of df) idf.set(t, Math.log((N + 1) / (n + 1)) + 1);
|
|
1790
|
+
return idf;
|
|
1791
|
+
}
|
|
1792
|
+
function relevanceScore(text, queryTokens, idf) {
|
|
1793
|
+
if (queryTokens.size === 0) return 0;
|
|
1794
|
+
const t = tokenize(text);
|
|
1795
|
+
let score = 0;
|
|
1796
|
+
for (const q of queryTokens) if (t.has(q)) score += idf?.get(q) ?? 1;
|
|
1797
|
+
return score;
|
|
1798
|
+
}
|
|
1799
|
+
function topByRelevance(items, query, text, k, corpus) {
|
|
1800
|
+
if (!Number.isInteger(k) || k < 1) return { kept: items, rest: [] };
|
|
1801
|
+
if (items.length <= k || !query.trim()) return { kept: items, rest: [] };
|
|
1802
|
+
const q = tokenize(query);
|
|
1803
|
+
if (q.size === 0) return { kept: items.slice(0, k), rest: items.slice(k) };
|
|
1804
|
+
const idf = corpus?.length ? idfWeights(corpus) : void 0;
|
|
1805
|
+
const scored = items.map((x, i) => ({ i, s: relevanceScore(text(x), q, idf) }));
|
|
1806
|
+
scored.sort((a, b) => b.s - a.s || a.i - b.i);
|
|
1807
|
+
const keep = new Set(scored.slice(0, k).map((e) => e.i));
|
|
1808
|
+
return { kept: items.filter((_, i) => keep.has(i)), rest: items.filter((_, i) => !keep.has(i)) };
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// src/frontmatter.ts
|
|
1812
|
+
function splitFrontmatter(md) {
|
|
1813
|
+
const m = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
|
|
1814
|
+
return { block: m ? m[1] : "", body: (m ? md.slice(m[0].length) : md).trim() };
|
|
1815
|
+
}
|
|
1816
|
+
function grabField(block, key) {
|
|
1817
|
+
const lines = block.split("\n");
|
|
1818
|
+
const i = lines.findIndex((l) => new RegExp(`^${key}\\s*:`, "i").test(l));
|
|
1819
|
+
if (i < 0) return void 0;
|
|
1820
|
+
const inline = lines[i].replace(new RegExp(`^${key}\\s*:\\s*`, "i"), "");
|
|
1821
|
+
if (/^[>|][+-]?\s*$/.test(inline)) {
|
|
1822
|
+
const folded = [];
|
|
1823
|
+
for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j++) folded.push(lines[j].trim());
|
|
1824
|
+
return folded.join(" ").trim() || void 0;
|
|
1825
|
+
}
|
|
1826
|
+
return inline.trim().replace(/^["']|["']$/g, "") || void 0;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/skills.ts
|
|
1830
|
+
var MAX_CATALOG = 25;
|
|
1831
|
+
function parseFrontmatter(md) {
|
|
1832
|
+
const { block } = splitFrontmatter(md);
|
|
1833
|
+
const b = block || md.slice(0, 600);
|
|
1834
|
+
return { name: grabField(b, "name"), description: grabField(b, "description") };
|
|
1835
|
+
}
|
|
1836
|
+
async function loadSkills(fs, dir, opts = {}) {
|
|
1837
|
+
const skills = [];
|
|
1838
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1839
|
+
for (const d of Array.isArray(dir) ? dir : [dir]) {
|
|
1840
|
+
if (!await fs.exists(d)) continue;
|
|
1841
|
+
for (const entry of await fs.readDir(d)) {
|
|
1842
|
+
if (seen.has(entry)) continue;
|
|
1843
|
+
const skillMd = `${d}/${entry}/SKILL.md`;
|
|
1844
|
+
if (await fs.exists(skillMd)) {
|
|
1845
|
+
seen.add(entry);
|
|
1846
|
+
const fm = parseFrontmatter(await fs.readFile(skillMd));
|
|
1847
|
+
skills.push({ name: fm.name || entry, description: fm.description || "", path: skillMd });
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (skills.length === 0) return { skills, catalog: "" };
|
|
1852
|
+
const { kept, rest } = topByRelevance(skills, opts.relevanceHint ?? "", (s) => `${s.name} ${s.description}`, opts.max ?? MAX_CATALOG);
|
|
1853
|
+
const catalog = "## Skills (load one before acting on a matching task)\n" + kept.map((s) => `- **${s.name}** \u2014 ${s.description} (\`${s.path}\`)`).join("\n") + (rest.length ? `
|
|
1854
|
+
- (${rest.length} more, names only \u2014 call \`Skill\` to load): ${rest.map((s) => s.name).join(", ")}` : "") + "\nWhen a task matches a skill, call the `Skill` tool with its name to load its full instructions.";
|
|
1855
|
+
const tool = {
|
|
1856
|
+
name: "Skill",
|
|
1857
|
+
description: "Load a skill by name \u2014 returns its full instructions (SKILL.md). Use when a task matches a listed skill.",
|
|
1858
|
+
parameters: { type: "object", required: ["name"], properties: { name: { type: "string" } } },
|
|
1859
|
+
async run({ name }, ctx) {
|
|
1860
|
+
const s = skills.find((x) => x.name === name);
|
|
1861
|
+
if (!s) return `Error: no skill named '${name}'. Available: ${skills.map((x) => x.name).join(", ")}`;
|
|
1862
|
+
return ctx.fs.readFile(s.path);
|
|
1863
|
+
}
|
|
1864
|
+
};
|
|
1865
|
+
return { skills, catalog, tool };
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// src/commands.ts
|
|
1869
|
+
var MAX_CATALOG2 = 25;
|
|
1870
|
+
function parseFrontmatter2(md) {
|
|
1871
|
+
const { block, body } = splitFrontmatter(md);
|
|
1872
|
+
return { description: grabField(block, "description"), body };
|
|
1873
|
+
}
|
|
1874
|
+
function expandTemplate(body, args) {
|
|
1875
|
+
if (!/\$ARGUMENTS|\$[1-9]/.test(body)) return args ? `${body}
|
|
1876
|
+
|
|
1877
|
+
${args}` : body;
|
|
1878
|
+
const toks = args.trim() ? args.trim().split(/\s+/) : [];
|
|
1879
|
+
return body.split("$ARGUMENTS").join(args).replace(/\$([1-9])/g, (_m, n) => toks[Number(n) - 1] ?? "");
|
|
1880
|
+
}
|
|
1881
|
+
async function embedFiles(text, fs) {
|
|
1882
|
+
const refs = [...new Set([...text.matchAll(/(?:^|\s)@(\S+)/g)].map((m) => m[1]))];
|
|
1883
|
+
let out = text;
|
|
1884
|
+
for (const ref of refs) {
|
|
1885
|
+
try {
|
|
1886
|
+
const content = await fs.readFile(ref);
|
|
1887
|
+
out = out.split("@" + ref).join(`
|
|
1888
|
+
|
|
1889
|
+
--- @${ref} ---
|
|
1890
|
+
${content}
|
|
1891
|
+
--- end @${ref} ---
|
|
1892
|
+
`);
|
|
1893
|
+
} catch {
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return out;
|
|
1897
|
+
}
|
|
1898
|
+
async function expandCommand(fs, cmd, args) {
|
|
1899
|
+
const { body } = parseFrontmatter2(await fs.readFile(cmd.path));
|
|
1900
|
+
return embedFiles(expandTemplate(body, args), fs);
|
|
1901
|
+
}
|
|
1902
|
+
async function loadCommands(fs, dir, opts = {}) {
|
|
1903
|
+
const commands = [];
|
|
1904
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1905
|
+
for (const d of Array.isArray(dir) ? dir : [dir]) {
|
|
1906
|
+
if (!await fs.exists(d)) continue;
|
|
1907
|
+
for (const entry of await fs.readDir(d)) {
|
|
1908
|
+
if (!entry.endsWith(".md")) continue;
|
|
1909
|
+
const name = entry.replace(/\.md$/, "");
|
|
1910
|
+
if (seen.has(name)) continue;
|
|
1911
|
+
seen.add(name);
|
|
1912
|
+
const path = `${d}/${entry}`;
|
|
1913
|
+
const fm = parseFrontmatter2(await fs.readFile(path));
|
|
1914
|
+
commands.push({ name, description: fm.description || "", path });
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
if (commands.length === 0) return { commands, catalog: "" };
|
|
1918
|
+
const { kept, rest } = topByRelevance(commands, opts.relevanceHint ?? "", (c) => `${c.name} ${c.description}`, opts.max ?? MAX_CATALOG2);
|
|
1919
|
+
const catalog = "## Slash commands (reusable prompt templates)\n" + kept.map((c) => `- **/${c.name}** \u2014 ${c.description} (\`${c.path}\`)`).join("\n") + (rest.length ? `
|
|
1920
|
+
- (${rest.length} more, names only \u2014 call \`SlashCommand\` to run): ${rest.map((c) => c.name).join(", ")}` : "") + "\nTo run one, call the `SlashCommand` tool with its name (and optional `args`); it returns the expanded template to act on.";
|
|
1921
|
+
const tool = {
|
|
1922
|
+
name: "SlashCommand",
|
|
1923
|
+
description: "Run a slash command by name \u2014 returns its expanded prompt template (with your `args` substituted/appended). Use when a task matches a listed command.",
|
|
1924
|
+
parameters: {
|
|
1925
|
+
type: "object",
|
|
1926
|
+
required: ["name"],
|
|
1927
|
+
properties: { name: { type: "string" }, args: { type: "string", description: "arguments to fill the template" } }
|
|
1928
|
+
},
|
|
1929
|
+
async run({ name, args }, ctx) {
|
|
1930
|
+
const slug = String(name ?? "").replace(/^\//, "");
|
|
1931
|
+
const c = commands.find((x) => x.name === slug);
|
|
1932
|
+
if (!c) return `Error: no command named '${slug}'. Available: ${commands.map((x) => x.name).join(", ")}`;
|
|
1933
|
+
return expandCommand(ctx.fs, c, String(args ?? ""));
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
return { commands, catalog, tool };
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// src/memory.ts
|
|
1940
|
+
init_tools_structured();
|
|
1941
|
+
var MAX_INDEX = 25;
|
|
1942
|
+
async function loadMemory(fs, dir, opts = {}) {
|
|
1943
|
+
const indexPath = `${dir}/MEMORY.md`;
|
|
1944
|
+
const tools = [recallTool(fs, dir), rememberTool(fs, dir), memorySearchTool(fs, dir)];
|
|
1945
|
+
const md = await fs.exists(indexPath) ? (await fs.readFile(indexPath)).trim() : "";
|
|
1946
|
+
if (!md) return { index: "", tools };
|
|
1947
|
+
const lines = md.split("\n");
|
|
1948
|
+
const pointers = lines.filter((l) => /^\s*-\s*\[.+\]\(.+\.md\)/.test(l));
|
|
1949
|
+
const header = lines.filter((l) => !/^\s*-\s*\[.+\]\(.+\.md\)/.test(l)).join("\n").trim();
|
|
1950
|
+
const { kept, rest } = topByRelevance(pointers, opts.relevanceHint ?? "", (l) => l, opts.max ?? MAX_INDEX);
|
|
1951
|
+
const restSlugs = rest.map((l) => l.match(/\]\(([^)]+)\.md\)/)?.[1] ?? l.match(/\[([^\]]+)\]/)?.[1] ?? "").filter(Boolean);
|
|
1952
|
+
const index = "## Memory (persistent context \u2014 recalled across sessions)\n" + (header ? header + "\n" : "") + kept.join("\n") + (restSlugs.length ? `
|
|
1953
|
+
- (${restSlugs.length} more learnings, slugs only \u2014 call \`Recall\` if relevant): ${restSlugs.join(", ")}` : "") + `
|
|
1954
|
+
|
|
1955
|
+
These are pointers only. Call \`Recall\` with a slug (or multiple slugs/a pattern) to load full bodies when relevant.
|
|
1956
|
+
Call \`MemorySearch\` with a query to find memories by content when you don't know the slug.
|
|
1957
|
+
Call \`Remember\` to persist a durable fact for future sessions.`;
|
|
1958
|
+
return { index, tools };
|
|
1959
|
+
}
|
|
1960
|
+
function slugify(s, fallback = "note") {
|
|
1961
|
+
const base = String(s ?? "").trim().toLowerCase().replace(/\.md$/i, "").replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
|
|
1962
|
+
return base || fallback;
|
|
1963
|
+
}
|
|
1964
|
+
async function writeFact(fs, dir, slug, body) {
|
|
1965
|
+
await mkdirp(fs, dir);
|
|
1966
|
+
await fs.writeFile(`${dir}/${slug}.md`, body.endsWith("\n") ? body : body + "\n");
|
|
1967
|
+
const indexPath = `${dir}/MEMORY.md`;
|
|
1968
|
+
const idx = await fs.exists(indexPath) ? await fs.readFile(indexPath) : "# Memory Index\n";
|
|
1969
|
+
const line = `- [${slug}](${slug}.md) \u2014 ${body.split("\n")[0].slice(0, 80)}`;
|
|
1970
|
+
const lines = idx.split("\n");
|
|
1971
|
+
const at = lines.findIndex((l) => l.includes(`(${slug}.md)`));
|
|
1972
|
+
if (at >= 0) {
|
|
1973
|
+
if (lines[at] !== line) {
|
|
1974
|
+
lines[at] = line;
|
|
1975
|
+
await fs.writeFile(indexPath, lines.join("\n"));
|
|
1976
|
+
}
|
|
1977
|
+
} else {
|
|
1978
|
+
await fs.writeFile(indexPath, idx.replace(/\s*$/, "") + `
|
|
1979
|
+
${line}
|
|
1980
|
+
`);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
function cleanSlug(raw) {
|
|
1984
|
+
let s = String(raw ?? "").trim().replace(/\.md$/i, "").replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
|
|
1985
|
+
while (s.includes("..")) s = s.replace(/\.\./g, "");
|
|
1986
|
+
return s;
|
|
1987
|
+
}
|
|
1988
|
+
async function listSlugs(fs, dir, prefix = "") {
|
|
1989
|
+
let entries;
|
|
1990
|
+
try {
|
|
1991
|
+
entries = await fs.readDir(dir);
|
|
1992
|
+
} catch {
|
|
1993
|
+
return [];
|
|
1994
|
+
}
|
|
1995
|
+
const out = [];
|
|
1996
|
+
for (const e of entries.sort()) {
|
|
1997
|
+
const full = dir === "/" ? `/${e}` : `${dir}/${e}`;
|
|
1998
|
+
const rel = prefix ? `${prefix}/${e}` : e;
|
|
1999
|
+
if (await fs.isDirectory(full)) out.push(...await listSlugs(fs, full, rel));
|
|
2000
|
+
else if (e.endsWith(".md") && e !== "MEMORY.md") out.push(rel.replace(/\.md$/, ""));
|
|
2001
|
+
}
|
|
2002
|
+
return out;
|
|
2003
|
+
}
|
|
2004
|
+
async function loadFact(fs, dir, slug) {
|
|
2005
|
+
const path = `${dir}/${slug}.md`;
|
|
2006
|
+
try {
|
|
2007
|
+
return await fs.readFile(path);
|
|
2008
|
+
} catch {
|
|
2009
|
+
return null;
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
function recallTool(fs, dir) {
|
|
2013
|
+
return {
|
|
2014
|
+
name: "Recall",
|
|
2015
|
+
description: 'Load memory facts by slug. Pass `slug` for one, `slugs` for several, or `pattern` (glob like "auth*") to match. Returns full bodies, separated by `--- slug ---` headers.',
|
|
2016
|
+
parameters: {
|
|
2017
|
+
type: "object",
|
|
2018
|
+
properties: {
|
|
2019
|
+
slug: { type: "string", description: "single fact slug (filename without .md)" },
|
|
2020
|
+
slugs: { type: "array", items: { type: "string" }, description: "multiple slugs to load at once" },
|
|
2021
|
+
pattern: { type: "string", description: 'glob pattern to match against slugs (e.g. "auth*", "*database*")' }
|
|
2022
|
+
}
|
|
2023
|
+
},
|
|
2024
|
+
async run({ slug, slugs, pattern }, ctx) {
|
|
2025
|
+
let targets = [];
|
|
2026
|
+
if (slug) targets = [cleanSlug(slug)];
|
|
2027
|
+
else if (Array.isArray(slugs)) targets = slugs.map(cleanSlug).filter(Boolean);
|
|
2028
|
+
else if (pattern) {
|
|
2029
|
+
const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
2030
|
+
const re = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i");
|
|
2031
|
+
targets = (await listSlugs(ctx.fs, dir)).filter((s) => re.test(s));
|
|
2032
|
+
}
|
|
2033
|
+
if (!targets.length) return `Error: no slugs resolved. Pass slug, slugs, or pattern.`;
|
|
2034
|
+
const parts = [];
|
|
2035
|
+
for (const s of targets.slice(0, 20)) {
|
|
2036
|
+
const body = await loadFact(ctx.fs, dir, s);
|
|
2037
|
+
if (body != null) parts.push(targets.length > 1 ? `--- ${s} ---
|
|
2038
|
+
${body}` : body);
|
|
2039
|
+
else parts.push(targets.length > 1 ? `--- ${s} ---
|
|
2040
|
+
[not found]` : `Error: no memory fact '${s}'.`);
|
|
2041
|
+
}
|
|
2042
|
+
if (targets.length > 20) parts.push(`(${targets.length - 20} more matched \u2014 narrow the pattern)`);
|
|
2043
|
+
return parts.join("\n\n");
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
function memorySearchTool(fs, dir) {
|
|
2048
|
+
return {
|
|
2049
|
+
name: "MemorySearch",
|
|
2050
|
+
description: "Search memory facts by content \u2014 find relevant memories when you don't know the exact slug. Returns up to 10 matching slug + snippet pairs, ranked by relevance. Use `regex: true` for regex patterns.",
|
|
2051
|
+
parameters: {
|
|
2052
|
+
type: "object",
|
|
2053
|
+
required: ["query"],
|
|
2054
|
+
properties: {
|
|
2055
|
+
query: { type: "string", description: "search query (keywords or regex pattern)" },
|
|
2056
|
+
regex: { type: "boolean", description: "treat query as a regex pattern (default: false)" }
|
|
2057
|
+
}
|
|
2058
|
+
},
|
|
2059
|
+
async run({ query, regex }, ctx) {
|
|
2060
|
+
const q = String(query ?? "").trim();
|
|
2061
|
+
if (!q) return "Error: empty query.";
|
|
2062
|
+
const slugs = await listSlugs(ctx.fs, dir);
|
|
2063
|
+
if (!slugs.length) return "(no memory facts found)";
|
|
2064
|
+
let matcher;
|
|
2065
|
+
if (regex) {
|
|
2066
|
+
try {
|
|
2067
|
+
const re = new RegExp(q, "i");
|
|
2068
|
+
matcher = (l) => re.test(l);
|
|
2069
|
+
} catch (e) {
|
|
2070
|
+
return `Error: invalid regex: ${e}`;
|
|
2071
|
+
}
|
|
2072
|
+
} else {
|
|
2073
|
+
const lower = q.toLowerCase();
|
|
2074
|
+
matcher = (l) => l.toLowerCase().includes(lower);
|
|
2075
|
+
}
|
|
2076
|
+
const loaded = [];
|
|
2077
|
+
for (const slug of slugs) {
|
|
2078
|
+
const body = await loadFact(ctx.fs, dir, slug);
|
|
2079
|
+
if (body) loaded.push({ slug, body });
|
|
2080
|
+
}
|
|
2081
|
+
const idf = idfWeights(loaded.map((l) => l.body));
|
|
2082
|
+
const qTokens = tokenize(q);
|
|
2083
|
+
const hits = [];
|
|
2084
|
+
for (const { slug, body } of loaded) {
|
|
2085
|
+
const lines = body.split("\n");
|
|
2086
|
+
const matchLine = lines.find(matcher);
|
|
2087
|
+
if (!matchLine && !relevanceScore(body, qTokens, idf)) continue;
|
|
2088
|
+
const snippet = matchLine?.trim().slice(0, 120) || lines.find((l) => l.trim())?.trim().slice(0, 120) || "";
|
|
2089
|
+
hits.push({ slug, snippet, score: relevanceScore(body, qTokens, idf) });
|
|
2090
|
+
}
|
|
2091
|
+
if (!hits.length) return "(no matches)";
|
|
2092
|
+
hits.sort((a, b) => b.score - a.score);
|
|
2093
|
+
return hits.slice(0, 10).map((h) => `${h.slug}: ${h.snippet}`).join("\n");
|
|
2094
|
+
}
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
function rememberTool(fs, dir) {
|
|
2098
|
+
return {
|
|
2099
|
+
name: "Remember",
|
|
2100
|
+
description: "Persist a durable fact for future sessions (a fix you found, a gotcha, a project constraint). Adds a pointer to the Memory index and stores the body. Use sparingly \u2014 only genuinely reusable knowledge, not task-specific scratch.",
|
|
2101
|
+
parameters: {
|
|
2102
|
+
type: "object",
|
|
2103
|
+
required: ["fact"],
|
|
2104
|
+
properties: {
|
|
2105
|
+
fact: { type: "string", description: "the durable fact to remember (one or more lines)" },
|
|
2106
|
+
slug: { type: "string", description: "optional kebab-case id; derived from the fact if omitted" }
|
|
2107
|
+
}
|
|
2108
|
+
},
|
|
2109
|
+
async run({ fact, slug }, ctx) {
|
|
2110
|
+
const body = String(fact ?? "").trim();
|
|
2111
|
+
if (!body) return `Error: nothing to remember (empty fact).`;
|
|
2112
|
+
const name = slugify(slug || body.split("\n")[0]);
|
|
2113
|
+
await writeFact(ctx.fs, dir, name, body);
|
|
2114
|
+
return `Remembered '${name}' (recallable in future sessions).`;
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// src/instructions.ts
|
|
2120
|
+
var DEFAULT_NAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md"];
|
|
2121
|
+
async function loadInstructions(fs, names = DEFAULT_NAMES) {
|
|
2122
|
+
const cwd = fs.getCwd();
|
|
2123
|
+
const base = cwd === "/" ? "" : cwd;
|
|
2124
|
+
for (const name of names) {
|
|
2125
|
+
const sections = [];
|
|
2126
|
+
for (const path of [`${base}/${name}`, `${base}/.claude/${name}`]) {
|
|
2127
|
+
if (!await fs.exists(path)) continue;
|
|
2128
|
+
const md = (await fs.readFile(path)).trim();
|
|
2129
|
+
if (md) sections.push(`<!-- ${path} -->
|
|
2130
|
+
${md}`);
|
|
2131
|
+
}
|
|
2132
|
+
if (sections.length) {
|
|
2133
|
+
return `## Project instructions
|
|
2134
|
+
Repository-specific instructions \u2014 follow them.
|
|
2135
|
+
|
|
2136
|
+
${sections.join("\n\n---\n\n")}`;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
return "";
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// src/subagent.ts
|
|
2143
|
+
init_tools();
|
|
2144
|
+
init_OverlayFilesystem();
|
|
2145
|
+
async function boundedPool(items, limit, fn) {
|
|
2146
|
+
const out = new Array(items.length);
|
|
2147
|
+
let next = 0;
|
|
2148
|
+
const worker = async () => {
|
|
2149
|
+
while (next < items.length) {
|
|
2150
|
+
const i = next++;
|
|
2151
|
+
out[i] = await fn(items[i], i);
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
2155
|
+
return out;
|
|
2156
|
+
}
|
|
2157
|
+
function childOptionsFor(opts, fs, depth, maxDepth, agentType) {
|
|
2158
|
+
let def;
|
|
2159
|
+
if (agentType) {
|
|
2160
|
+
def = (opts.agents ?? []).find((a) => a.name === agentType);
|
|
2161
|
+
if (!def) return `no subagent type '${agentType}'. Available: ${(opts.agents ?? []).map((a) => a.name).join(", ") || "(none defined)"}`;
|
|
2162
|
+
}
|
|
2163
|
+
const childOpts = { ai: opts.ai, fs, model: def?.model ?? opts.model, subagents: true, depth: depth + 1, maxDepth };
|
|
2164
|
+
if (opts.maxSteps != null) childOpts.maxSteps = opts.maxSteps;
|
|
2165
|
+
if (def?.systemPrompt) childOpts.systemPrompt = def.systemPrompt;
|
|
2166
|
+
if (def?.tools?.length) {
|
|
2167
|
+
try {
|
|
2168
|
+
childOpts.tools = toolsByName(def.tools);
|
|
2169
|
+
} catch (e) {
|
|
2170
|
+
return `subagent '${agentType}' declares an unknown tool \u2014 ${e.message}`;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
return childOpts;
|
|
2174
|
+
}
|
|
2175
|
+
function makeTaskTool(opts) {
|
|
2176
|
+
const depth = opts.depth ?? 0;
|
|
2177
|
+
const maxDepth = opts.maxDepth ?? 2;
|
|
2178
|
+
return {
|
|
2179
|
+
name: "Task",
|
|
2180
|
+
description: "Delegate a self-contained sub-task to a child agent over the same filesystem. It runs autonomously with its own step budget and returns a concise summary \u2014 use to isolate context-heavy work (broad search, a scoped refactor). Provide a short `description` and a full `prompt`. Set `background: true` to run it detached (returns a job id to poll with JobOutput while you keep working); its file edits are overlay-isolated and commit when it finishes.",
|
|
2181
|
+
parameters: {
|
|
2182
|
+
type: "object",
|
|
2183
|
+
required: ["description", "prompt"],
|
|
2184
|
+
properties: {
|
|
2185
|
+
description: { type: "string", description: "a short (3-5 word) label for the sub-task" },
|
|
2186
|
+
prompt: { type: "string", description: "the full instructions the child agent should carry out" },
|
|
2187
|
+
agentType: { type: "string", description: "optional named subagent type (its persona, model, and scoped tools) \u2014 see the catalog" },
|
|
2188
|
+
background: { type: "boolean", description: "run detached (overlay-isolated, commits on finish); returns a job id to poll with JobOutput instead of blocking" }
|
|
2189
|
+
}
|
|
2190
|
+
},
|
|
2191
|
+
async run({ description, prompt, agentType, background }, ctx) {
|
|
2192
|
+
if (depth >= maxDepth) {
|
|
2193
|
+
return `Error: Task depth limit reached (maxDepth ${maxDepth}). Cannot spawn another child agent \u2014 do this work directly instead.`;
|
|
2194
|
+
}
|
|
2195
|
+
const label = String(description ?? agentType ?? "sub-task");
|
|
2196
|
+
if (background && ctx.jobs) {
|
|
2197
|
+
const id = ctx.jobs.start(async ({ signal }) => {
|
|
2198
|
+
const overlay = new OverlayFilesystem(opts.fs);
|
|
2199
|
+
const childOpts2 = childOptionsFor(opts, overlay, depth, maxDepth, agentType);
|
|
2200
|
+
if (typeof childOpts2 === "string") throw new Error(childOpts2);
|
|
2201
|
+
childOpts2.signal = signal;
|
|
2202
|
+
const res2 = await new Agent(childOpts2).run(String(prompt ?? ""));
|
|
2203
|
+
if (signal.aborted) return "[killed before commit]";
|
|
2204
|
+
await overlay.commit();
|
|
2205
|
+
const summary2 = res2.text || `(child '${label}' finished with no summary; finishReason=${res2.finishReason})`;
|
|
2206
|
+
await opts.hooks?.onSubagentStop?.(summary2, { label, agentType });
|
|
2207
|
+
return summary2;
|
|
2208
|
+
}, { kind: "agent", label });
|
|
2209
|
+
return `Started background sub-task ${id} ('${label}') \u2014 poll with JobOutput({id:"${id}"}).`;
|
|
2210
|
+
}
|
|
2211
|
+
const childOpts = childOptionsFor(opts, opts.fs, depth, maxDepth, agentType);
|
|
2212
|
+
if (typeof childOpts === "string") return `Error: ${childOpts}`;
|
|
2213
|
+
const child = new Agent(childOpts);
|
|
2214
|
+
const res = await child.run(String(prompt ?? ""));
|
|
2215
|
+
const summary = res.text || `(child agent finished '${label}' with no summary; finishReason=${res.finishReason})`;
|
|
2216
|
+
await opts.hooks?.onSubagentStop?.(summary, { label, agentType });
|
|
2217
|
+
return summary;
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
function makeTaskBatchTool(opts) {
|
|
2222
|
+
const depth = opts.depth ?? 0;
|
|
2223
|
+
const maxDepth = opts.maxDepth ?? 2;
|
|
2224
|
+
const maxParallel = opts.maxParallel ?? 4;
|
|
2225
|
+
return {
|
|
2226
|
+
name: "TaskBatch",
|
|
2227
|
+
description: "Delegate SEVERAL independent sub-tasks to child agents that run concurrently; returns all their summaries. Each child is write-isolated (its file edits are merged back in array order). Use for parallel fan-out (review/search/scoped refactors across files). Provide `tasks: [{ description, prompt, agentType? }]`.",
|
|
2228
|
+
parameters: {
|
|
2229
|
+
type: "object",
|
|
2230
|
+
required: ["tasks"],
|
|
2231
|
+
properties: {
|
|
2232
|
+
tasks: {
|
|
2233
|
+
type: "array",
|
|
2234
|
+
description: "the sub-tasks to run in parallel",
|
|
2235
|
+
items: {
|
|
2236
|
+
type: "object",
|
|
2237
|
+
required: ["description", "prompt"],
|
|
2238
|
+
properties: {
|
|
2239
|
+
description: { type: "string", description: "a short (3-5 word) label" },
|
|
2240
|
+
prompt: { type: "string", description: "the full instructions for this child" },
|
|
2241
|
+
agentType: { type: "string", description: "optional named subagent type" }
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
},
|
|
2247
|
+
async run({ tasks }, _ctx) {
|
|
2248
|
+
if (depth >= maxDepth) return `Error: Task depth limit reached (maxDepth ${maxDepth}). Cannot spawn child agents \u2014 do this work directly instead.`;
|
|
2249
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
2250
|
+
if (!list.length) return "Error: TaskBatch needs a non-empty `tasks` array.";
|
|
2251
|
+
const results = await boundedPool(list, maxParallel, async (t, i) => {
|
|
2252
|
+
const label = String(t?.description ?? t?.agentType ?? `task ${i + 1}`);
|
|
2253
|
+
const overlay = new OverlayFilesystem(opts.fs);
|
|
2254
|
+
const childOpts = childOptionsFor(opts, overlay, depth, maxDepth, t?.agentType);
|
|
2255
|
+
if (typeof childOpts === "string") return { label, error: childOpts, ok: false };
|
|
2256
|
+
try {
|
|
2257
|
+
const res = await new Agent(childOpts).run(String(t?.prompt ?? ""));
|
|
2258
|
+
await opts.hooks?.onSubagentStop?.(res.text, { label, agentType: t?.agentType });
|
|
2259
|
+
return { label, text: res.text, overlay, ok: res.finishReason !== "error" };
|
|
2260
|
+
} catch (e) {
|
|
2261
|
+
return { label, error: e instanceof Error ? e.message : String(e), ok: false };
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
for (const r of results) if (r.ok && r.overlay) await r.overlay.commit().catch(() => {
|
|
2265
|
+
});
|
|
2266
|
+
return results.map((r, i) => `### ${i + 1}. ${r.label}
|
|
2267
|
+
${r.error ? `ERROR: ${r.error}` : r.text || "(no summary)"}`).join("\n\n");
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// src/agents.ts
|
|
2273
|
+
function parseAgentFrontmatter(md) {
|
|
2274
|
+
const m = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
|
|
2275
|
+
const block = m ? m[1] : "";
|
|
2276
|
+
const get = (k) => {
|
|
2277
|
+
const r = new RegExp(`^${k}\\s*:\\s*(.+)$`, "im").exec(block);
|
|
2278
|
+
return r ? r[1].trim().replace(/^["']|["']$/g, "") : void 0;
|
|
2279
|
+
};
|
|
2280
|
+
const toolsRaw = get("tools");
|
|
2281
|
+
const tools = toolsRaw ? toolsRaw.replace(/^\[|\]$/g, "").split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean) : void 0;
|
|
2282
|
+
return { description: get("description"), model: get("model"), tools, body: (m ? md.slice(m[0].length) : md).trim() };
|
|
2283
|
+
}
|
|
2284
|
+
async function loadAgents(fs, dir) {
|
|
2285
|
+
const agents = [];
|
|
2286
|
+
if (await fs.exists(dir)) {
|
|
2287
|
+
for (const entry of await fs.readDir(dir)) {
|
|
2288
|
+
if (!entry.endsWith(".md")) continue;
|
|
2289
|
+
const fm = parseAgentFrontmatter(await fs.readFile(`${dir}/${entry}`));
|
|
2290
|
+
agents.push({
|
|
2291
|
+
name: entry.replace(/\.md$/, ""),
|
|
2292
|
+
description: fm.description || "",
|
|
2293
|
+
systemPrompt: fm.body || void 0,
|
|
2294
|
+
model: fm.model,
|
|
2295
|
+
tools: fm.tools
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
if (!agents.length) return { agents, catalog: "" };
|
|
2300
|
+
const catalog = "## Subagent types (pass as the `Task` tool `agentType`)\n" + agents.map((a) => `- **${a.name}** \u2014 ${a.description}`).join("\n");
|
|
2301
|
+
return { agents, catalog };
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// src/Agent.ts
|
|
2305
|
+
init_OverlayFilesystem();
|
|
2306
|
+
|
|
2307
|
+
// src/permissions.ts
|
|
2308
|
+
init_tools_structured();
|
|
2309
|
+
function commandMatches(glob, cmd) {
|
|
2310
|
+
const pattern = glob.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
2311
|
+
return new RegExp("^" + pattern + "$").test(cmd);
|
|
2312
|
+
}
|
|
2313
|
+
var PermissionOptions = class {
|
|
2314
|
+
rules = [];
|
|
2315
|
+
/** decision when no rule matches. */
|
|
2316
|
+
default = "allow";
|
|
2317
|
+
/** host channel for `ask` (confirm). If absent, `ask` is treated as `deny` (fail-closed). */
|
|
2318
|
+
host;
|
|
2319
|
+
};
|
|
2320
|
+
var PermissionPolicy = class {
|
|
2321
|
+
options;
|
|
2322
|
+
constructor(options) {
|
|
2323
|
+
this.options = { ...new PermissionOptions(), ...options };
|
|
2324
|
+
}
|
|
2325
|
+
/** Resolve the decision for a tool call (first matching rule, else default). */
|
|
2326
|
+
decide(call) {
|
|
2327
|
+
for (const r of this.options.rules) {
|
|
2328
|
+
if (r.tool && r.tool !== call.name) continue;
|
|
2329
|
+
if (r.pathGlob) {
|
|
2330
|
+
const path = typeof call.args?.path === "string" ? call.args.path : null;
|
|
2331
|
+
const cmd = typeof call.args?.command === "string" ? call.args.command : null;
|
|
2332
|
+
if (path != null) {
|
|
2333
|
+
if (!globToRegExp(r.pathGlob).test(path.startsWith("/") ? path : `/${path}`)) continue;
|
|
2334
|
+
} else if (cmd != null) {
|
|
2335
|
+
if (!commandMatches(r.pathGlob, cmd)) continue;
|
|
2336
|
+
} else continue;
|
|
2337
|
+
}
|
|
2338
|
+
return r.decision;
|
|
2339
|
+
}
|
|
2340
|
+
return this.options.default;
|
|
2341
|
+
}
|
|
2342
|
+
/** A preToolUse hook enforcing this policy (deny/ask → block; allow → proceed). */
|
|
2343
|
+
hooks() {
|
|
2344
|
+
return {
|
|
2345
|
+
preToolUse: async (call) => {
|
|
2346
|
+
const d = this.decide(call);
|
|
2347
|
+
if (d === "allow") return;
|
|
2348
|
+
if (d === "deny") return { block: true, reason: `denied by permission policy (${call.name})` };
|
|
2349
|
+
if (this.options.ask) {
|
|
2350
|
+
const r = await this.options.ask(call);
|
|
2351
|
+
const decision = r?.decision ?? "deny";
|
|
2352
|
+
if (r?.remember) this.options.rules.unshift({ tool: call.name, decision });
|
|
2353
|
+
return decision === "allow" ? void 0 : { block: true, reason: `not approved (${call.name})` };
|
|
2354
|
+
}
|
|
2355
|
+
const ok = await this.options.host?.confirm?.(`Allow ${call.name}${call.args?.path ? " on " + call.args.path : ""}?`);
|
|
2356
|
+
return ok ? void 0 : { block: true, reason: `not approved (${call.name})` };
|
|
2357
|
+
}
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
};
|
|
2361
|
+
var DEFAULT_MUTATING = ["Write", "Edit", "MultiEdit", "deleteFile", "bash"];
|
|
2362
|
+
function planMode(opts) {
|
|
2363
|
+
const mutating = new Set(opts?.mutating ?? DEFAULT_MUTATING);
|
|
2364
|
+
let approved = false;
|
|
2365
|
+
const tool = {
|
|
2366
|
+
name: "ExitPlanMode",
|
|
2367
|
+
description: "Call when your plan is ready and you want to start making changes. Provide the `plan`. Until this is approved, edits/writes are blocked.",
|
|
2368
|
+
parameters: { type: "object", required: ["plan"], properties: { plan: { type: "string", description: "the concrete steps you will take" } } },
|
|
2369
|
+
async run({ plan }, _ctx) {
|
|
2370
|
+
if (opts?.host?.confirm) {
|
|
2371
|
+
const ok = await opts.host.confirm(`Approve this plan?
|
|
2372
|
+
|
|
2373
|
+
${String(plan ?? "")}`);
|
|
2374
|
+
if (!ok) return "Plan not approved. Revise it and call ExitPlanMode again.";
|
|
2375
|
+
}
|
|
2376
|
+
approved = true;
|
|
2377
|
+
return "Plan approved \u2014 you may now make changes.";
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
const hooks = {
|
|
2381
|
+
preToolUse: (call) => !approved && mutating.has(call.name) ? { block: true, reason: "plan mode: present a plan and call ExitPlanMode (approved) before editing." } : void 0
|
|
2382
|
+
};
|
|
2383
|
+
return { hooks, tool };
|
|
2384
|
+
}
|
|
2385
|
+
function composeHooks(...list) {
|
|
2386
|
+
const hooks = list.filter(Boolean);
|
|
2387
|
+
return {
|
|
2388
|
+
async preToolUse(call, meta) {
|
|
2389
|
+
for (const h of hooks) {
|
|
2390
|
+
const d = await h.preToolUse?.(call, meta);
|
|
2391
|
+
if (d?.block) return d;
|
|
2392
|
+
}
|
|
2393
|
+
},
|
|
2394
|
+
async postToolUse(call, result, meta) {
|
|
2395
|
+
for (const h of hooks) await h.postToolUse?.(call, result, meta);
|
|
2396
|
+
},
|
|
2397
|
+
onStop(text) {
|
|
2398
|
+
for (const h of hooks) h.onStop?.(text);
|
|
2399
|
+
},
|
|
2400
|
+
// lifecycle: concatenate session-start context; chain prompt-submit transforms; fan out pre-compact.
|
|
2401
|
+
async onSessionStart() {
|
|
2402
|
+
let ctx = "";
|
|
2403
|
+
for (const h of hooks) {
|
|
2404
|
+
const r = await h.onSessionStart?.();
|
|
2405
|
+
if (r) ctx += (ctx ? "\n\n" : "") + r;
|
|
2406
|
+
}
|
|
2407
|
+
return ctx || void 0;
|
|
2408
|
+
},
|
|
2409
|
+
async onUserPromptSubmit(text) {
|
|
2410
|
+
let t = text;
|
|
2411
|
+
for (const h of hooks) {
|
|
2412
|
+
const r = await h.onUserPromptSubmit?.(t);
|
|
2413
|
+
if (typeof r === "string") t = r;
|
|
2414
|
+
}
|
|
2415
|
+
return t;
|
|
2416
|
+
},
|
|
2417
|
+
async onPreCompact(messages) {
|
|
2418
|
+
for (const h of hooks) await h.onPreCompact?.(messages);
|
|
2419
|
+
},
|
|
2420
|
+
async onSubagentStop(summary, info) {
|
|
2421
|
+
for (const h of hooks) await h.onSubagentStop?.(summary, info);
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// src/Agent.ts
|
|
2427
|
+
init_logging();
|
|
2428
|
+
|
|
2429
|
+
// src/lint.ts
|
|
2430
|
+
var CODE_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
2431
|
+
function checkSyntax(path, content) {
|
|
2432
|
+
if (!CODE_RE.test(path)) return null;
|
|
2433
|
+
try {
|
|
2434
|
+
const open = { ")": "(", "]": "[", "}": "{" };
|
|
2435
|
+
const names = { "(": "'('", "[": "'['", "{": "'{'" };
|
|
2436
|
+
const stack = [];
|
|
2437
|
+
const n = content.length;
|
|
2438
|
+
for (let i = 0; i < n; i++) {
|
|
2439
|
+
const c = content[i];
|
|
2440
|
+
if (c === "/" && content[i + 1] === "/") {
|
|
2441
|
+
while (i < n && content[i] !== "\n") i++;
|
|
2442
|
+
continue;
|
|
2443
|
+
}
|
|
2444
|
+
if (c === "/" && content[i + 1] === "*") {
|
|
2445
|
+
i += 2;
|
|
2446
|
+
while (i < n && !(content[i] === "*" && content[i + 1] === "/")) i++;
|
|
2447
|
+
i++;
|
|
2448
|
+
continue;
|
|
2449
|
+
}
|
|
2450
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
2451
|
+
const quote = c;
|
|
2452
|
+
i++;
|
|
2453
|
+
while (i < n) {
|
|
2454
|
+
if (content[i] === "\\") {
|
|
2455
|
+
i += 2;
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
if (content[i] === quote) break;
|
|
2459
|
+
i++;
|
|
2460
|
+
}
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
if (c === "(" || c === "[" || c === "{") stack.push(c);
|
|
2464
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
2465
|
+
const want = open[c];
|
|
2466
|
+
if (stack.length === 0) return `Syntax check failed for ${path}: unexpected closing '${c}'. Fix before writing.`;
|
|
2467
|
+
const top = stack.pop();
|
|
2468
|
+
if (top !== want) return `Syntax check failed for ${path}: mismatched '${c}' (expected to close ${names[top]}). Fix before writing.`;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
if (stack.length > 0) {
|
|
2472
|
+
const ch = stack[stack.length - 1];
|
|
2473
|
+
return `Syntax check failed for ${path}: unbalanced ${names[ch]} (${stack.length} unclosed). Fix before writing.`;
|
|
2474
|
+
}
|
|
2475
|
+
return null;
|
|
2476
|
+
} catch {
|
|
2477
|
+
return null;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// src/Agent.ts
|
|
2482
|
+
var log3 = forComponent("Agent");
|
|
2483
|
+
var AgentOptions = class {
|
|
2484
|
+
/** Any ai.libx.js AIClient (or a FakeAIClient). */
|
|
2485
|
+
ai;
|
|
2486
|
+
/** Filesystem backend — MemFilesystem, NodeDiskFilesystem, IndexedDbFilesystem, …
|
|
2487
|
+
* OPTIONAL: if omitted, the agent lazily defaults to a JAILED real disk rooted at `process.cwd()`
|
|
2488
|
+
* (secrets hidden by DEFAULT_DENY) — resolved via a dynamic node import at first run, so the edge/
|
|
2489
|
+
* browser build (which always passes its own fs) never pulls in `node:fs`. Pass `fs` explicitly to
|
|
2490
|
+
* use any other backend (and to keep full control of the jail). */
|
|
2491
|
+
fs;
|
|
2492
|
+
model = "anthropic/claude-sonnet-4-6";
|
|
2493
|
+
systemPrompt = 'You are a coding agent operating on a virtual filesystem. Use the `bash` tool to explore (ls, grep, find, cat) and the `Read`/`Edit` tools to view and modify files. Always Read a file before Editing it. When multiple tool calls are independent (e.g. several Reads or Greps with no dependency between them), issue them together in a single turn rather than one at a time. When the task is complete, reply with a short summary and make no further tool calls.\nWhen working with a knowledge base or document workspace: use `RepoMap` with scope "docs" to see all document headings and summaries (orient first); use `MemorySearch` to find memories by content when you don\'t know the exact slug; use `Recall` with multiple slugs to batch-load related facts in one call. Prefer targeted loads over reading everything \u2014 orient \u2192 search \u2192 load \u2192 act.';
|
|
2494
|
+
tools = defaultTools();
|
|
2495
|
+
maxSteps = 25;
|
|
2496
|
+
// --- automatic kill-switches (always on; protect the API budget against runaway loops/abuse) ---
|
|
2497
|
+
/** Hard ceiling on accumulated tokens (prompt+completion) across the run. 0 = unbounded. */
|
|
2498
|
+
maxTokens = 2e5;
|
|
2499
|
+
/** Wall-clock ceiling in ms for the whole run. 0 = unbounded. */
|
|
2500
|
+
timeoutMs = 12e4;
|
|
2501
|
+
/** Stop if the identical tool-call batch (name+args) repeats this many times in a row. 0 = off. */
|
|
2502
|
+
maxRepeats = 3;
|
|
2503
|
+
/** Cumulative cap on tool calls dispatched across the run. 0 = unbounded. */
|
|
2504
|
+
maxToolCalls = 100;
|
|
2505
|
+
/** External cancellation — abort the loop between steps (e.g. a UI "Cancel" button). */
|
|
2506
|
+
signal;
|
|
2507
|
+
/** 0 = never trim. Otherwise cap the messages sent per turn (system + most recent). */
|
|
2508
|
+
maxContextMessages = 0;
|
|
2509
|
+
/** Note-taking: keep the most-recent N tool-result outputs verbatim; collapse OLDER ones to a one-line
|
|
2510
|
+
* stub in the sent context (the model already consumed them — it can re-Read/re-run). The stored
|
|
2511
|
+
* transcript is never mutated. 0 = keep all verbatim. */
|
|
2512
|
+
keepToolOutputs = 0;
|
|
2513
|
+
/** Token-aware backstop (~4 chars/token estimate). After note-taking, drop oldest messages from the
|
|
2514
|
+
* sent context until the estimate is under this ceiling (pairing-safe). 0 = off. */
|
|
2515
|
+
maxContextTokens = 0;
|
|
2516
|
+
/** VFS dir(s) of skills (`<dir>/<id>/SKILL.md`). If set: inject a catalog + add the `Skill` tool. Multiple dirs are merged (first wins on name collisions). */
|
|
2517
|
+
skillsDir;
|
|
2518
|
+
/** VFS dir(s) of slash-command templates (`<dir>/<name>.md`). If set: inject a catalog + add the `SlashCommand` tool. Multiple dirs are merged (first wins). */
|
|
2519
|
+
commandsDir;
|
|
2520
|
+
/** VFS dir of memory (`<dir>/MEMORY.md`). If set: inject the index at run start (persistence = backend). */
|
|
2521
|
+
memoryDir;
|
|
2522
|
+
/** Filenames to discover as project instructions (e.g. `AGENT.md`, `AGENTS.md`, `CLAUDE.md`).
|
|
2523
|
+
* Walks the VFS tree and merges all found files (general → specific, like Claude Code).
|
|
2524
|
+
* `true` (default) = auto-discover standard names. `string[]` = custom names. `false` = skip. */
|
|
2525
|
+
instructionFiles = true;
|
|
2526
|
+
/** Host interaction channel (human-in-the-loop). If set: adds the `AskUserQuestion` tool. */
|
|
2527
|
+
host;
|
|
2528
|
+
/** Deterministic interception points around tool execution (pre/post/stop). */
|
|
2529
|
+
hooks;
|
|
2530
|
+
/** If true: add the `Task` tool so the agent can spawn depth-limited child agents over the VFS. */
|
|
2531
|
+
subagents = false;
|
|
2532
|
+
/** VFS dir of typed-subagent defs (`<dir>/<name>.md`). If set with `subagents`: inject a catalog + enable the `Task` `agentType` param. */
|
|
2533
|
+
agentsDir;
|
|
2534
|
+
/** Current recursion depth (0 = top-level); spawned children run at depth+1. */
|
|
2535
|
+
depth = 0;
|
|
2536
|
+
/** Hard ceiling on subagent nesting (beyond it the `Task` tool refuses to spawn). */
|
|
2537
|
+
maxDepth = 2;
|
|
2538
|
+
/** Stream tokens from the model. Takes effect only with a `host.notify`; off => current (non-stream) behavior. */
|
|
2539
|
+
stream = false;
|
|
2540
|
+
/** Fold the dropped middle of an over-long transcript into a synthetic summary (edge-safe, no LLM). Off => drop-oldest. */
|
|
2541
|
+
compaction;
|
|
2542
|
+
/** Add `Checkpoint`/`Rollback` tools (requires the fs to be an OverlayFilesystem). */
|
|
2543
|
+
checkpoints = false;
|
|
2544
|
+
/** Enable `bash({background:true})` + JobOutput/JobStatus/JobKill — sandbox background jobs (overlay-isolated,
|
|
2545
|
+
* committed on completion, drained at turn end). Useful when the VFS backend is slow (remote) or for sub-agents. */
|
|
2546
|
+
backgroundJobs = false;
|
|
2547
|
+
/** Plan mode: block mutating tools until the agent calls `ExitPlanMode` (host-approved). */
|
|
2548
|
+
planMode = false;
|
|
2549
|
+
/** Permission policy gating each tool call (allow / ask / deny). */
|
|
2550
|
+
permissions;
|
|
2551
|
+
/** Opt-in syntax guardrail: refuse to persist a syntactically-broken code-file write/edit. Default off. */
|
|
2552
|
+
lintOnWrite;
|
|
2553
|
+
/** Opt-in: after a write-class tool runs, run `command` over the VFS and append any failure to the tool result.
|
|
2554
|
+
* `tools` defaults to ['Write','Edit','MultiEdit','ApplyEdits']. */
|
|
2555
|
+
autoTest;
|
|
2556
|
+
};
|
|
2557
|
+
var Agent = class _Agent {
|
|
2558
|
+
options;
|
|
2559
|
+
transcript = [];
|
|
2560
|
+
ctx;
|
|
2561
|
+
// built in the ctor when `fs` is provided, else lazily in ensureFs()
|
|
2562
|
+
activeTools = [];
|
|
2563
|
+
activeHooks;
|
|
2564
|
+
// composed: user hooks + plan-mode + permissions
|
|
2565
|
+
prepared = false;
|
|
2566
|
+
// memo guard: prompt/tools/plan-state built once per conversation
|
|
2567
|
+
systemPromptCache = "";
|
|
2568
|
+
// the assembled system prompt from the last prepare()
|
|
2569
|
+
started = false;
|
|
2570
|
+
// session-start lifecycle hook fires once per conversation
|
|
2571
|
+
/** Inject tools into a running agent (e.g. dynamically mounted MCP servers). Takes effect on the next turn. */
|
|
2572
|
+
addTools(tools) {
|
|
2573
|
+
this.activeTools.push(...tools);
|
|
2574
|
+
}
|
|
2575
|
+
/** Remove tools by name from a running agent. Returns the count removed. */
|
|
2576
|
+
removeTools(names) {
|
|
2577
|
+
const s = names instanceof Set ? names : new Set(names);
|
|
2578
|
+
const before = this.activeTools.length;
|
|
2579
|
+
this.activeTools = this.activeTools.filter((t) => !s.has(t.name));
|
|
2580
|
+
return before - this.activeTools.length;
|
|
2581
|
+
}
|
|
2582
|
+
constructor(options) {
|
|
2583
|
+
this.options = { ...new AgentOptions(), ...options };
|
|
2584
|
+
if (this.options.fs) this.buildCtx();
|
|
2585
|
+
}
|
|
2586
|
+
/** Build the tool context from the resolved fs + options. Idempotent-safe: called once (ctor or ensureFs). */
|
|
2587
|
+
buildCtx() {
|
|
2588
|
+
this.ctx = makeContext(this.options.fs, this.options.host);
|
|
2589
|
+
this.ctx.signal = this.options.signal;
|
|
2590
|
+
if (this.options.lintOnWrite) this.ctx.lint = checkSyntax;
|
|
2591
|
+
this.ctx.ai = this.options.ai;
|
|
2592
|
+
this.ctx.model = this.options.model;
|
|
2593
|
+
if (this.options.backgroundJobs) this.ctx.jobs = new SandboxJobRegistry();
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Resolve the filesystem + build the tool context if the ctor couldn't (no `fs` was passed). The disk
|
|
2597
|
+
* default lives HERE, not in the ctor: the ctor can't await, and we must avoid a STATIC `node:fs` import
|
|
2598
|
+
* in the core (edge/browser would break). The dynamic import only fires when `fs` is omitted — edge code
|
|
2599
|
+
* always passes its own `MemFilesystem`, so the node module is never reached. Default = jailed disk @ cwd.
|
|
2600
|
+
*/
|
|
2601
|
+
async ensureFs() {
|
|
2602
|
+
if (this.ctx) return;
|
|
2603
|
+
if (!this.options.fs) {
|
|
2604
|
+
const { NodeDiskFilesystem: NodeDiskFilesystem2 } = await Promise.resolve().then(() => (init_NodeDiskFilesystem(), NodeDiskFilesystem_exports));
|
|
2605
|
+
const { JailedFilesystem: JailedFilesystem2 } = await Promise.resolve().then(() => (init_JailedFilesystem(), JailedFilesystem_exports));
|
|
2606
|
+
const disk = new NodeDiskFilesystem2(process.cwd());
|
|
2607
|
+
await disk.init();
|
|
2608
|
+
this.options.fs = new JailedFilesystem2(disk);
|
|
2609
|
+
log3.info(`no fs provided \u2014 defaulting to jailed real disk at ${process.cwd()}`);
|
|
2610
|
+
}
|
|
2611
|
+
this.buildCtx();
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Assemble the system prompt + active tools/hooks ONCE per conversation (memoized).
|
|
2615
|
+
* `run()` resets the memo to start fresh; `send()` reuses it, so multi-turn state —
|
|
2616
|
+
* plan-mode approval, the tool set, the skills/commands/memory snapshot — stays stable
|
|
2617
|
+
* across turns (and the frozen prompt pairs well with prompt caching). Does NOT touch
|
|
2618
|
+
* the transcript; `run`/`send` own that.
|
|
2619
|
+
*/
|
|
2620
|
+
async prepare(taskHint) {
|
|
2621
|
+
if (this.prepared) return this.systemPromptCache;
|
|
2622
|
+
const o = this.options;
|
|
2623
|
+
const fs = o.fs;
|
|
2624
|
+
let systemPrompt = o.systemPrompt;
|
|
2625
|
+
let tools = o.tools;
|
|
2626
|
+
if (o.instructionFiles !== false) {
|
|
2627
|
+
const names = Array.isArray(o.instructionFiles) ? o.instructionFiles : void 0;
|
|
2628
|
+
const ins = await loadInstructions(fs, names);
|
|
2629
|
+
if (ins) systemPrompt += "\n\n" + ins;
|
|
2630
|
+
}
|
|
2631
|
+
if (o.memoryDir) {
|
|
2632
|
+
const { index, tools: memTools } = await loadMemory(fs, o.memoryDir, { relevanceHint: taskHint });
|
|
2633
|
+
if (index) systemPrompt += "\n\n" + index;
|
|
2634
|
+
tools = [...tools, ...memTools];
|
|
2635
|
+
}
|
|
2636
|
+
if (o.skillsDir) {
|
|
2637
|
+
const { catalog, tool } = await loadSkills(fs, o.skillsDir, { relevanceHint: taskHint });
|
|
2638
|
+
if (catalog) systemPrompt += "\n\n" + catalog;
|
|
2639
|
+
if (tool) tools = [...tools, tool];
|
|
2640
|
+
}
|
|
2641
|
+
if (o.commandsDir) {
|
|
2642
|
+
const { catalog, tool } = await loadCommands(fs, o.commandsDir, { relevanceHint: taskHint });
|
|
2643
|
+
if (catalog) systemPrompt += "\n\n" + catalog;
|
|
2644
|
+
if (tool) tools = [...tools, tool];
|
|
2645
|
+
}
|
|
2646
|
+
if (o.host) tools = [...tools, askUserQuestionTool];
|
|
2647
|
+
if (o.subagents) {
|
|
2648
|
+
let agents;
|
|
2649
|
+
if (o.agentsDir) {
|
|
2650
|
+
const loaded = await loadAgents(fs, o.agentsDir);
|
|
2651
|
+
agents = loaded.agents;
|
|
2652
|
+
if (loaded.catalog) systemPrompt += "\n\n" + loaded.catalog;
|
|
2653
|
+
}
|
|
2654
|
+
const taskOpts = { ai: o.ai, model: o.model, fs, depth: o.depth, maxDepth: o.maxDepth, agents, hooks: o.hooks };
|
|
2655
|
+
tools = [...tools, makeTaskTool(taskOpts), makeTaskBatchTool(taskOpts)];
|
|
2656
|
+
}
|
|
2657
|
+
if (o.checkpoints) tools = [...tools, ...checkpointTools()];
|
|
2658
|
+
if (this.ctx.jobs) tools = [...tools, ...makeJobTools(this.ctx.jobs)];
|
|
2659
|
+
const plan = o.planMode ? planMode({ host: o.host }) : void 0;
|
|
2660
|
+
if (plan) tools = [...tools, plan.tool];
|
|
2661
|
+
this.activeHooks = composeHooks(o.hooks, plan?.hooks, o.permissions?.hooks());
|
|
2662
|
+
this.activeTools = tools;
|
|
2663
|
+
this.systemPromptCache = systemPrompt;
|
|
2664
|
+
this.prepared = true;
|
|
2665
|
+
return systemPrompt;
|
|
2666
|
+
}
|
|
2667
|
+
/** Single-shot: reset all per-conversation state and run `task` to completion. `task` may be plain
|
|
2668
|
+
* text or multimodal content parts (text + images). */
|
|
2669
|
+
async run(task) {
|
|
2670
|
+
await this.ensureFs();
|
|
2671
|
+
this.prepared = false;
|
|
2672
|
+
this.started = false;
|
|
2673
|
+
const systemPrompt = await this.prepare(contentText(task));
|
|
2674
|
+
const startCtx = await this.fireSessionStart();
|
|
2675
|
+
const userContent = await this.applyPromptSubmit(task);
|
|
2676
|
+
this.transcript = [
|
|
2677
|
+
{ role: "system", content: systemPrompt + (startCtx ? "\n\n" + startCtx : "") },
|
|
2678
|
+
{ role: "user", content: userContent }
|
|
2679
|
+
];
|
|
2680
|
+
return this.runLoop();
|
|
2681
|
+
}
|
|
2682
|
+
/** Apply onUserPromptSubmit to a turn: rewrite plain text; pass multimodal content through untouched. */
|
|
2683
|
+
async applyPromptSubmit(task) {
|
|
2684
|
+
return typeof task === "string" ? await this.fireUserPromptSubmit(task) : task;
|
|
2685
|
+
}
|
|
2686
|
+
/** Fire onSessionStart once per conversation; returns any injected context. */
|
|
2687
|
+
async fireSessionStart() {
|
|
2688
|
+
if (this.started) return void 0;
|
|
2689
|
+
this.started = true;
|
|
2690
|
+
const ctx = await this.activeHooks?.onSessionStart?.();
|
|
2691
|
+
return typeof ctx === "string" && ctx ? ctx : void 0;
|
|
2692
|
+
}
|
|
2693
|
+
/** Fire onUserPromptSubmit; returns the (possibly rewritten) prompt text. */
|
|
2694
|
+
async fireUserPromptSubmit(task) {
|
|
2695
|
+
const r = await this.activeHooks?.onUserPromptSubmit?.(task);
|
|
2696
|
+
return typeof r === "string" ? r : task;
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* Multi-turn: continue the existing conversation by appending a user turn instead of
|
|
2700
|
+
* resetting — this is what makes an interactive REPL feel like one conversation. The
|
|
2701
|
+
* leading system message is (re)synced to the current prompt, so a resumed session whose
|
|
2702
|
+
* tools were rebuilt gets a matching catalog rather than a stale one.
|
|
2703
|
+
*/
|
|
2704
|
+
async send(task) {
|
|
2705
|
+
await this.ensureFs();
|
|
2706
|
+
const systemPrompt = await this.prepare(contentText(task));
|
|
2707
|
+
const startCtx = await this.fireSessionStart();
|
|
2708
|
+
const userContent = await this.applyPromptSubmit(task);
|
|
2709
|
+
const sys = systemPrompt + (startCtx ? "\n\n" + startCtx : "");
|
|
2710
|
+
if (this.transcript[0]?.role === "system") this.transcript[0] = { role: "system", content: sys };
|
|
2711
|
+
else this.transcript.unshift({ role: "system", content: sys });
|
|
2712
|
+
this.transcript.push({ role: "user", content: userContent });
|
|
2713
|
+
return this.runLoop();
|
|
2714
|
+
}
|
|
2715
|
+
/**
|
|
2716
|
+
* Fold the conversation in place (manual `/compact`): keep the system message + the
|
|
2717
|
+
* most-recent window, summarizing the dropped middle (deterministic, no LLM call).
|
|
2718
|
+
* No-op when the transcript already fits. Returns the number of messages removed.
|
|
2719
|
+
*/
|
|
2720
|
+
compactNow(maxMessages = 12) {
|
|
2721
|
+
const max = Math.max(2, maxMessages);
|
|
2722
|
+
if (this.transcript.length <= max) return 0;
|
|
2723
|
+
void this.activeHooks?.onPreCompact?.(this.transcript);
|
|
2724
|
+
const before = this.transcript.length;
|
|
2725
|
+
this.transcript = compact(this.transcript, max);
|
|
2726
|
+
return before - this.transcript.length;
|
|
2727
|
+
}
|
|
2728
|
+
async runLoop() {
|
|
2729
|
+
const o = this.options;
|
|
2730
|
+
const wireTools = toWireTools(this.activeTools);
|
|
2731
|
+
const useStream = o.stream === true && typeof o.host?.notify === "function";
|
|
2732
|
+
let steps = 0;
|
|
2733
|
+
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
2734
|
+
let usageEstimated = false;
|
|
2735
|
+
const start = Date.now();
|
|
2736
|
+
let toolCallsTotal = 0;
|
|
2737
|
+
let lastFp = "";
|
|
2738
|
+
let repeats = 0;
|
|
2739
|
+
const kill = (finishReason) => {
|
|
2740
|
+
log3.warn(`kill-switch: ${finishReason} (steps=${steps}, tokens=${usage.totalTokens}, ms=${Date.now() - start})`);
|
|
2741
|
+
this.ctx.jobs?.killAll();
|
|
2742
|
+
return { text: lastAssistantText(this.transcript), steps, finishReason, messages: this.transcript, usage, usageEstimated };
|
|
2743
|
+
};
|
|
2744
|
+
while (true) {
|
|
2745
|
+
if (o.signal?.aborted) return kill("aborted");
|
|
2746
|
+
if (steps >= o.maxSteps) return kill("max_steps");
|
|
2747
|
+
if (o.timeoutMs && Date.now() - start >= o.timeoutMs) return kill("timeout");
|
|
2748
|
+
if (o.maxTokens && usage.totalTokens >= o.maxTokens) return kill("budget");
|
|
2749
|
+
steps++;
|
|
2750
|
+
let res;
|
|
2751
|
+
const sent = this.trimContext();
|
|
2752
|
+
try {
|
|
2753
|
+
if (useStream) {
|
|
2754
|
+
const r = await o.ai.chat({ model: o.model, messages: sent, tools: wireTools, stream: true, signal: o.signal });
|
|
2755
|
+
res = await this.consumeStream(r);
|
|
2756
|
+
} else {
|
|
2757
|
+
const r = await o.ai.chat({ model: o.model, messages: sent, tools: wireTools, stream: false, signal: o.signal });
|
|
2758
|
+
res = r;
|
|
2759
|
+
}
|
|
2760
|
+
} catch (err) {
|
|
2761
|
+
if (err?.code === "budget") return kill("budget");
|
|
2762
|
+
if (o.signal?.aborted) return kill("aborted");
|
|
2763
|
+
log3.error("chat() failed", err);
|
|
2764
|
+
return { text: "", steps, finishReason: "error", messages: this.transcript, usage, usageEstimated, error: err };
|
|
2765
|
+
}
|
|
2766
|
+
if (o.signal?.aborted) return kill("aborted");
|
|
2767
|
+
if (!res.usage) {
|
|
2768
|
+
const promptTokens = estimateTokens(sent);
|
|
2769
|
+
const completionTokens = Math.ceil((contentText(res.content).length + (res.toolCalls ? JSON.stringify(res.toolCalls).length : 0)) / 4);
|
|
2770
|
+
res.usage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
|
|
2771
|
+
usageEstimated = true;
|
|
2772
|
+
}
|
|
2773
|
+
if (res.usage) {
|
|
2774
|
+
usage.promptTokens += res.usage.promptTokens ?? 0;
|
|
2775
|
+
usage.completionTokens += res.usage.completionTokens ?? 0;
|
|
2776
|
+
usage.totalTokens += res.usage.totalTokens ?? 0;
|
|
2777
|
+
}
|
|
2778
|
+
const toolCalls = res.toolCalls ?? [];
|
|
2779
|
+
this.transcript.push({
|
|
2780
|
+
role: "assistant",
|
|
2781
|
+
content: res.content ?? "",
|
|
2782
|
+
...toolCalls.length ? { tool_calls: toolCalls } : {}
|
|
2783
|
+
});
|
|
2784
|
+
if (toolCalls.length === 0) {
|
|
2785
|
+
log3.verbose(`completed in ${steps} step(s)`);
|
|
2786
|
+
await this.ctx.jobs?.drain();
|
|
2787
|
+
await this.activeHooks?.onStop?.(res.content ?? "");
|
|
2788
|
+
return { text: res.content ?? "", steps, finishReason: "stop", messages: this.transcript, usage, usageEstimated };
|
|
2789
|
+
}
|
|
2790
|
+
const fp = toolCalls.map((tc) => tc.function.name + ":" + (tc.function.arguments ?? "")).join("|");
|
|
2791
|
+
repeats = fp === lastFp ? repeats + 1 : 1;
|
|
2792
|
+
lastFp = fp;
|
|
2793
|
+
if (o.maxRepeats && repeats >= o.maxRepeats) return kill("loop");
|
|
2794
|
+
toolCallsTotal += toolCalls.length;
|
|
2795
|
+
if (o.maxToolCalls && toolCallsTotal > o.maxToolCalls) return kill("max_tool_calls");
|
|
2796
|
+
for (const tc of toolCalls) {
|
|
2797
|
+
const content = await this.dispatch(tc);
|
|
2798
|
+
this.transcript.push({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content });
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
/**
|
|
2803
|
+
* Drain a streamed chat() response: emit each text delta to the host
|
|
2804
|
+
* (`{kind:'text_delta'}`) and fold all chunks back into the single
|
|
2805
|
+
* ChatResponse the non-stream path would have returned.
|
|
2806
|
+
*/
|
|
2807
|
+
async consumeStream(stream) {
|
|
2808
|
+
let content = "";
|
|
2809
|
+
let finishReason;
|
|
2810
|
+
let toolCalls;
|
|
2811
|
+
let usage;
|
|
2812
|
+
for await (const chunk of stream) {
|
|
2813
|
+
if (this.options.signal?.aborted) break;
|
|
2814
|
+
if (chunk.reasoningContent) this.options.host.notify({ kind: "thinking_delta", message: chunk.reasoningContent });
|
|
2815
|
+
if (chunk.content) {
|
|
2816
|
+
content += chunk.content;
|
|
2817
|
+
this.options.host.notify({ kind: "text_delta", message: chunk.content });
|
|
2818
|
+
}
|
|
2819
|
+
if (chunk.finishReason) finishReason = chunk.finishReason;
|
|
2820
|
+
if (chunk.toolCalls?.length) toolCalls = chunk.toolCalls;
|
|
2821
|
+
if (chunk.usage) usage = chunk.usage;
|
|
2822
|
+
}
|
|
2823
|
+
return { content, ...finishReason ? { finishReason } : {}, ...toolCalls ? { toolCalls } : {}, ...usage ? { usage } : {} };
|
|
2824
|
+
}
|
|
2825
|
+
async dispatch(tc) {
|
|
2826
|
+
const tool = this.activeTools.find((t) => t.name === tc.function.name);
|
|
2827
|
+
if (!tool) return `Error: unknown tool '${tc.function.name}'`;
|
|
2828
|
+
let args = {};
|
|
2829
|
+
try {
|
|
2830
|
+
args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
|
|
2831
|
+
} catch (e) {
|
|
2832
|
+
return `Error: invalid JSON arguments for ${tc.function.name}: ${String(e)}`;
|
|
2833
|
+
}
|
|
2834
|
+
const hooks = this.activeHooks;
|
|
2835
|
+
const call = { name: tc.function.name, args };
|
|
2836
|
+
const meta = { id: tc.id };
|
|
2837
|
+
const decision = await hooks?.preToolUse?.(call, meta);
|
|
2838
|
+
if (decision?.block) {
|
|
2839
|
+
const blocked = `Blocked by hook: ${decision.reason ?? "no reason given"}`;
|
|
2840
|
+
log3.debug(`${tc.function.name} -> ${blocked}`);
|
|
2841
|
+
await hooks?.postToolUse?.(call, blocked, meta);
|
|
2842
|
+
return blocked;
|
|
2843
|
+
}
|
|
2844
|
+
this.options.host?.notify?.({ kind: "tool_use", id: tc.id ?? "", name: tc.function.name, input: args });
|
|
2845
|
+
let result;
|
|
2846
|
+
let threw = false;
|
|
2847
|
+
try {
|
|
2848
|
+
log3.debug(`${tc.function.name}(${tc.function.arguments})`);
|
|
2849
|
+
result = await tool.run(args, this.ctx);
|
|
2850
|
+
} catch (e) {
|
|
2851
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2852
|
+
log3.debug(`${tc.function.name} -> error: ${msg}`);
|
|
2853
|
+
result = `Error: ${msg}`;
|
|
2854
|
+
threw = true;
|
|
2855
|
+
}
|
|
2856
|
+
if (!threw) result = await this.maybeAutoTest(tc.function.name, result);
|
|
2857
|
+
await hooks?.postToolUse?.(call, result, meta);
|
|
2858
|
+
this.options.host?.notify?.({ kind: "tool_result", id: tc.id ?? "", output: result, isError: threw });
|
|
2859
|
+
return result;
|
|
2860
|
+
}
|
|
2861
|
+
static WRITE_CLASS = ["Write", "Edit", "MultiEdit", "ApplyEdits"];
|
|
2862
|
+
/** Append an autoTest failure section to a write-class tool result, if configured. */
|
|
2863
|
+
async maybeAutoTest(toolName, result) {
|
|
2864
|
+
const at = this.options.autoTest;
|
|
2865
|
+
if (!at?.command) return result;
|
|
2866
|
+
const set = at.tools ?? _Agent.WRITE_CLASS;
|
|
2867
|
+
if (!set.includes(toolName)) return result;
|
|
2868
|
+
const r = await this.ctx.exec.execute(at.command);
|
|
2869
|
+
if (r.exitCode === 0) return result;
|
|
2870
|
+
const out = truncateOutput(((r.output ?? "") + (r.error ?? "")).replace(/\n+$/, ""));
|
|
2871
|
+
return `${result}
|
|
2872
|
+
|
|
2873
|
+
[autoTest] \`${at.command}\` FAILED (exit ${r.exitCode}):
|
|
2874
|
+
${out}`;
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Shape the per-turn context (a VIEW — the stored transcript is never mutated). Layered, each off by default:
|
|
2878
|
+
* 1. coarse reduction — `compaction` (fold the dropped middle into a synthetic summary) OR
|
|
2879
|
+
* `maxContextMessages` (pure drop-oldest), keeping the system message + most-recent window.
|
|
2880
|
+
* 2. note-taking (`keepToolOutputs`) — collapse all-but-the-recent-N tool-result bodies to one-line stubs
|
|
2881
|
+
* (kills "immortal tool results": a big Read/Grep stops costing its full weight once it's old).
|
|
2882
|
+
* 3. token-aware backstop (`maxContextTokens`) — drop oldest messages until under an estimated token ceiling.
|
|
2883
|
+
* With every knob off, returns the transcript unchanged (same reference).
|
|
2884
|
+
*/
|
|
2885
|
+
trimContext() {
|
|
2886
|
+
const o = this.options;
|
|
2887
|
+
const m = this.transcript;
|
|
2888
|
+
let out = null;
|
|
2889
|
+
if (o.compaction?.maxMessages && m.length > o.compaction.maxMessages) out = compact(m, o.compaction.maxMessages);
|
|
2890
|
+
else if (o.maxContextMessages && m.length > o.maxContextMessages) out = dropOldest(m, o.maxContextMessages);
|
|
2891
|
+
if (o.keepToolOutputs) out = stubOldToolResults(out ?? m, o.keepToolOutputs);
|
|
2892
|
+
if (o.maxContextTokens) out = fitTokenBudget(out ?? m, o.maxContextTokens);
|
|
2893
|
+
return dropOrphanToolResults(out ?? m);
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
function dropOldest(m, max) {
|
|
2897
|
+
const head = m[0]?.role === "system" ? [m[0]] : [];
|
|
2898
|
+
return [...head, ...m.slice(-(max - head.length))];
|
|
2899
|
+
}
|
|
2900
|
+
function estimateTokens(m) {
|
|
2901
|
+
let chars = 0;
|
|
2902
|
+
for (const x of m) chars += contentText(x.content).length + (x.tool_calls ? JSON.stringify(x.tool_calls).length : 0);
|
|
2903
|
+
return Math.ceil(chars / 4);
|
|
2904
|
+
}
|
|
2905
|
+
function stubOldToolResults(messages, keep) {
|
|
2906
|
+
const meta = /* @__PURE__ */ new Map();
|
|
2907
|
+
for (const msg of messages)
|
|
2908
|
+
for (const tc of msg.tool_calls ?? []) {
|
|
2909
|
+
let path;
|
|
2910
|
+
try {
|
|
2911
|
+
const a = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
|
|
2912
|
+
if (typeof a.path === "string") path = a.path;
|
|
2913
|
+
} catch {
|
|
2914
|
+
}
|
|
2915
|
+
meta.set(tc.id, path);
|
|
2916
|
+
}
|
|
2917
|
+
const toolIdx = messages.map((x, i) => x.role === "tool" ? i : -1).filter((i) => i >= 0);
|
|
2918
|
+
const stub = new Set(toolIdx.slice(0, Math.max(0, toolIdx.length - keep)));
|
|
2919
|
+
if (stub.size === 0) return messages;
|
|
2920
|
+
return messages.map((x, i) => {
|
|
2921
|
+
const text = contentText(x.content);
|
|
2922
|
+
if (!stub.has(i) || text.length <= 120) return x;
|
|
2923
|
+
const where = meta.get(x.tool_call_id ?? "");
|
|
2924
|
+
const lines = text.split("\n").length;
|
|
2925
|
+
return { ...x, content: `[${x.name ?? "tool"}${where ? ` ${where}` : ""} output elided \u2014 ${lines} lines; re-run the tool to view]` };
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
var hasCallFor = (msgs, id) => msgs.some((m) => m.role === "assistant" && m.tool_calls?.some((tc) => tc.id === id));
|
|
2929
|
+
function dropOrphanToolResults(messages) {
|
|
2930
|
+
const ok = (m) => m.role !== "tool" || hasCallFor(messages, m.tool_call_id);
|
|
2931
|
+
return messages.every(ok) ? messages : messages.filter(ok);
|
|
2932
|
+
}
|
|
2933
|
+
function fitTokenBudget(messages, maxTokens) {
|
|
2934
|
+
if (estimateTokens(messages) <= maxTokens) return messages;
|
|
2935
|
+
const head = messages[0]?.role === "system" ? [messages[0]] : [];
|
|
2936
|
+
let body = messages.slice(head.length);
|
|
2937
|
+
while (body.length && estimateTokens([...head, ...body]) > maxTokens) body = body.slice(1);
|
|
2938
|
+
while (body.length && body[0].role === "tool" && !hasCallFor(body, body[0].tool_call_id)) body = body.slice(1);
|
|
2939
|
+
if (estimateTokens([...head, ...body]) > maxTokens)
|
|
2940
|
+
log3.warn(`context ~${estimateTokens([...head, ...body])} tok still over maxContextTokens=${maxTokens} after trimming (system head can't be dropped)`);
|
|
2941
|
+
return [...head, ...body];
|
|
2942
|
+
}
|
|
2943
|
+
function compact(m, max) {
|
|
2944
|
+
const hasSystem = m[0]?.role === "system";
|
|
2945
|
+
const head = hasSystem ? [m[0]] : [];
|
|
2946
|
+
const tailCount = Math.max(1, max - head.length - 1);
|
|
2947
|
+
const tail = m.slice(head.length).slice(-tailCount);
|
|
2948
|
+
const dropped = m.slice(head.length, m.length - tail.length);
|
|
2949
|
+
if (dropped.length === 0) return [...head, ...tail];
|
|
2950
|
+
return [...head, { role: "system", content: summarize(dropped) }, ...tail];
|
|
2951
|
+
}
|
|
2952
|
+
function summarize(messages) {
|
|
2953
|
+
const tools = /* @__PURE__ */ new Set();
|
|
2954
|
+
const files = /* @__PURE__ */ new Set();
|
|
2955
|
+
let lastAssistant = "";
|
|
2956
|
+
for (const msg of messages) {
|
|
2957
|
+
for (const tc of msg.tool_calls ?? []) {
|
|
2958
|
+
tools.add(tc.function.name);
|
|
2959
|
+
try {
|
|
2960
|
+
const args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
|
|
2961
|
+
if (typeof args.path === "string") files.add(args.path);
|
|
2962
|
+
} catch {
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
if (msg.role === "assistant" && msg.content) lastAssistant = contentText(msg.content);
|
|
2966
|
+
}
|
|
2967
|
+
const lines = [`[summary of ${messages.length} earlier message(s)]`];
|
|
2968
|
+
if (tools.size) lines.push(`tools used: ${[...tools].join(", ")}`);
|
|
2969
|
+
if (files.size) lines.push(`files touched: ${[...files].join(", ")}`);
|
|
2970
|
+
if (lastAssistant) lines.push(`last assistant: ${lastAssistant}`);
|
|
2971
|
+
return lines.join("\n");
|
|
2972
|
+
}
|
|
2973
|
+
function lastAssistantText(messages) {
|
|
2974
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2975
|
+
if (messages[i].role === "assistant") return contentText(messages[i].content);
|
|
2976
|
+
}
|
|
2977
|
+
return "";
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
// src/index.ts
|
|
2981
|
+
init_NodeDiskFilesystem();
|
|
2982
|
+
|
|
2983
|
+
// src/BodDbFilesystem.ts
|
|
2984
|
+
import { PathResolver as PathResolver2 } from "@livx.cc/wcli/core";
|
|
2985
|
+
import { BodDB } from "@bod.ee/db";
|
|
2986
|
+
var MemBackend = class {
|
|
2987
|
+
store = /* @__PURE__ */ new Map();
|
|
2988
|
+
async read(id) {
|
|
2989
|
+
const d = this.store.get(id);
|
|
2990
|
+
if (!d) throw new Error(`fileId not found: ${id}`);
|
|
2991
|
+
return d;
|
|
2992
|
+
}
|
|
2993
|
+
async write(id, data) {
|
|
2994
|
+
this.store.set(id, data);
|
|
2995
|
+
}
|
|
2996
|
+
async delete(id) {
|
|
2997
|
+
this.store.delete(id);
|
|
2998
|
+
}
|
|
2999
|
+
async exists(id) {
|
|
3000
|
+
return this.store.has(id);
|
|
3001
|
+
}
|
|
3002
|
+
};
|
|
3003
|
+
var BodDbFilesystem = class {
|
|
3004
|
+
cwd = "/";
|
|
3005
|
+
db;
|
|
3006
|
+
vfs;
|
|
3007
|
+
owns;
|
|
3008
|
+
constructor(db) {
|
|
3009
|
+
this.owns = !db;
|
|
3010
|
+
this.db = db ?? new BodDB({ path: ":memory:", sweepInterval: 0, vfs: { storageRoot: ":memory:", backend: new MemBackend() } });
|
|
3011
|
+
if (!this.db.vfs) throw new Error("BodDbFilesystem: the BodDB must have VFS enabled (construct it with { vfs: { \u2026 } }).");
|
|
3012
|
+
this.vfs = this.db.vfs;
|
|
3013
|
+
}
|
|
3014
|
+
/** Release the underlying DB's timers/handles. Closes only a DB we created (a passed-in DB is the caller's). */
|
|
3015
|
+
close() {
|
|
3016
|
+
if (this.owns) this.db.close();
|
|
3017
|
+
}
|
|
3018
|
+
resolvePath(path, cwd) {
|
|
3019
|
+
return PathResolver2.resolve(path, cwd || this.cwd);
|
|
3020
|
+
}
|
|
3021
|
+
getCwd() {
|
|
3022
|
+
return this.cwd;
|
|
3023
|
+
}
|
|
3024
|
+
setCwd(path) {
|
|
3025
|
+
this.cwd = PathResolver2.normalize(path);
|
|
3026
|
+
}
|
|
3027
|
+
/** Parent dir of an absolute VFS path ('/a/b.txt' -> '/a'; '/a' -> '/'). */
|
|
3028
|
+
parentOf(p) {
|
|
3029
|
+
const i = p.lastIndexOf("/");
|
|
3030
|
+
return i <= 0 ? "/" : p.slice(0, i);
|
|
3031
|
+
}
|
|
3032
|
+
/** Throw (matching the other backends) unless the parent directory exists. Root is always a dir. */
|
|
3033
|
+
assertParentDir(p, path) {
|
|
3034
|
+
const parent = this.parentOf(p);
|
|
3035
|
+
if (parent === "/") return;
|
|
3036
|
+
const s = this.vfs.stat(parent);
|
|
3037
|
+
if (!s || !s.isDir) throw new Error(`Parent directory does not exist: ${path}`);
|
|
3038
|
+
}
|
|
3039
|
+
async readFile(path) {
|
|
3040
|
+
const p = this.resolvePath(path);
|
|
3041
|
+
const s = this.vfs.stat(p);
|
|
3042
|
+
if (!s) throw new Error(`File not found: ${path}`);
|
|
3043
|
+
if (s.isDir) throw new Error(`Not a file: ${path}`);
|
|
3044
|
+
return new TextDecoder().decode(await this.vfs.read(p));
|
|
3045
|
+
}
|
|
3046
|
+
async writeFile(path, content) {
|
|
3047
|
+
const p = this.resolvePath(path);
|
|
3048
|
+
this.assertParentDir(p, path);
|
|
3049
|
+
if (this.vfs.stat(p)?.isDir) throw new Error(`Is a directory: ${path}`);
|
|
3050
|
+
await this.vfs.write(p, new TextEncoder().encode(content), "text/plain");
|
|
3051
|
+
}
|
|
3052
|
+
async deleteFile(path) {
|
|
3053
|
+
const p = this.resolvePath(path);
|
|
3054
|
+
const s = this.vfs.stat(p);
|
|
3055
|
+
if (!s) throw new Error(`File not found: ${path}`);
|
|
3056
|
+
if (s.isDir && this.vfs.list(p).length > 0) throw new Error(`Directory not empty: ${path}`);
|
|
3057
|
+
await this.vfs.remove(p);
|
|
3058
|
+
}
|
|
3059
|
+
async readDir(path) {
|
|
3060
|
+
const p = this.resolvePath(path);
|
|
3061
|
+
if (p !== "/") {
|
|
3062
|
+
const s = this.vfs.stat(p);
|
|
3063
|
+
if (!s) throw new Error(`Directory not found: ${path}`);
|
|
3064
|
+
if (!s.isDir) throw new Error(`Not a directory: ${path}`);
|
|
3065
|
+
}
|
|
3066
|
+
return this.vfs.list(p).map((e) => e.name);
|
|
3067
|
+
}
|
|
3068
|
+
async createDir(path) {
|
|
3069
|
+
const p = this.resolvePath(path);
|
|
3070
|
+
if (p === "/" || this.vfs.stat(p)) throw new Error(`File or directory already exists: ${path}`);
|
|
3071
|
+
this.assertParentDir(p, path);
|
|
3072
|
+
this.vfs.mkdir(p);
|
|
3073
|
+
}
|
|
3074
|
+
async exists(path) {
|
|
3075
|
+
const p = this.resolvePath(path);
|
|
3076
|
+
return p === "/" || this.vfs.stat(p) !== null;
|
|
3077
|
+
}
|
|
3078
|
+
async stat(path) {
|
|
3079
|
+
const p = this.resolvePath(path);
|
|
3080
|
+
const s = this.vfs.stat(p);
|
|
3081
|
+
if (!s) {
|
|
3082
|
+
if (p === "/") return { created: /* @__PURE__ */ new Date(0), modified: /* @__PURE__ */ new Date(0), size: 0, permissions: "drwxr-xr-x", isExecutable: false };
|
|
3083
|
+
throw new Error(`File not found: ${path}`);
|
|
3084
|
+
}
|
|
3085
|
+
const when = new Date(s.mtime);
|
|
3086
|
+
return {
|
|
3087
|
+
created: when,
|
|
3088
|
+
modified: when,
|
|
3089
|
+
size: s.size,
|
|
3090
|
+
permissions: s.isDir ? "drwxr-xr-x" : "-rw-r--r--",
|
|
3091
|
+
isExecutable: false
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
async isDirectory(path) {
|
|
3095
|
+
const p = this.resolvePath(path);
|
|
3096
|
+
return p === "/" || this.vfs.stat(p)?.isDir === true;
|
|
3097
|
+
}
|
|
3098
|
+
async isFile(path) {
|
|
3099
|
+
const s = this.vfs.stat(this.resolvePath(path));
|
|
3100
|
+
return !!s && !s.isDir;
|
|
3101
|
+
}
|
|
3102
|
+
};
|
|
3103
|
+
|
|
3104
|
+
// src/index.ts
|
|
3105
|
+
init_JailedFilesystem();
|
|
3106
|
+
init_OverlayFilesystem();
|
|
3107
|
+
|
|
3108
|
+
// src/MountFilesystem.ts
|
|
3109
|
+
var DIR_META = { created: /* @__PURE__ */ new Date(0), modified: /* @__PURE__ */ new Date(0), size: 0, permissions: "drwxr-xr-x", isExecutable: false };
|
|
3110
|
+
var norm = (p) => "/" + p.split("/").filter(Boolean).join("/");
|
|
3111
|
+
var MountFilesystem = class {
|
|
3112
|
+
constructor(root, mounts = []) {
|
|
3113
|
+
this.root = root;
|
|
3114
|
+
this.mounts = mounts.map((m) => ({ prefix: norm(m.prefix), fs: m.fs })).sort((a, b) => b.prefix.length - a.prefix.length);
|
|
3115
|
+
}
|
|
3116
|
+
root;
|
|
3117
|
+
mounts;
|
|
3118
|
+
resolvePath(path, cwd) {
|
|
3119
|
+
return this.root.resolvePath(path, cwd ?? this.root.getCwd());
|
|
3120
|
+
}
|
|
3121
|
+
getCwd() {
|
|
3122
|
+
return this.root.getCwd();
|
|
3123
|
+
}
|
|
3124
|
+
setCwd(path) {
|
|
3125
|
+
this.root.setCwd(path);
|
|
3126
|
+
}
|
|
3127
|
+
abs(p) {
|
|
3128
|
+
return this.resolvePath(p);
|
|
3129
|
+
}
|
|
3130
|
+
/** Route an absolute VFS path to a backend + its path within that backend. */
|
|
3131
|
+
route(abs) {
|
|
3132
|
+
for (const m of this.mounts) {
|
|
3133
|
+
if (abs === m.prefix || abs.startsWith(m.prefix + "/")) return { fs: m.fs, sub: abs.slice(m.prefix.length) || "/" };
|
|
3134
|
+
}
|
|
3135
|
+
return { fs: this.root, sub: abs };
|
|
3136
|
+
}
|
|
3137
|
+
/** Is `abs` strictly ABOVE a mount point (a synthesized dir the root may not have)? */
|
|
3138
|
+
isMountAncestor(abs) {
|
|
3139
|
+
const base = abs === "/" ? "/" : abs + "/";
|
|
3140
|
+
return this.mounts.some((m) => m.prefix.startsWith(base));
|
|
3141
|
+
}
|
|
3142
|
+
/** Immediate child segment names of `abs` that come from mounts (for readDir merge). */
|
|
3143
|
+
mountChildrenOf(abs) {
|
|
3144
|
+
const base = abs === "/" ? "/" : abs + "/";
|
|
3145
|
+
const out = /* @__PURE__ */ new Set();
|
|
3146
|
+
for (const m of this.mounts) if (m.prefix.startsWith(base)) out.add(m.prefix.slice(base.length).split("/")[0]);
|
|
3147
|
+
return [...out];
|
|
3148
|
+
}
|
|
3149
|
+
async readFile(path) {
|
|
3150
|
+
const { fs, sub } = this.route(this.abs(path));
|
|
3151
|
+
return fs.readFile(sub);
|
|
3152
|
+
}
|
|
3153
|
+
async writeFile(path, content) {
|
|
3154
|
+
const { fs, sub } = this.route(this.abs(path));
|
|
3155
|
+
return fs.writeFile(sub, content);
|
|
3156
|
+
}
|
|
3157
|
+
async deleteFile(path) {
|
|
3158
|
+
const { fs, sub } = this.route(this.abs(path));
|
|
3159
|
+
return fs.deleteFile(sub);
|
|
3160
|
+
}
|
|
3161
|
+
async createDir(path) {
|
|
3162
|
+
const { fs, sub } = this.route(this.abs(path));
|
|
3163
|
+
return fs.createDir(sub);
|
|
3164
|
+
}
|
|
3165
|
+
async exists(path) {
|
|
3166
|
+
const a = this.abs(path);
|
|
3167
|
+
if (this.isMountAncestor(a)) return true;
|
|
3168
|
+
const { fs, sub } = this.route(a);
|
|
3169
|
+
return fs.exists(sub);
|
|
3170
|
+
}
|
|
3171
|
+
async isDirectory(path) {
|
|
3172
|
+
const a = this.abs(path);
|
|
3173
|
+
if (this.isMountAncestor(a)) return true;
|
|
3174
|
+
const { fs, sub } = this.route(a);
|
|
3175
|
+
return fs.isDirectory(sub);
|
|
3176
|
+
}
|
|
3177
|
+
async isFile(path) {
|
|
3178
|
+
const a = this.abs(path);
|
|
3179
|
+
if (this.isMountAncestor(a)) return false;
|
|
3180
|
+
const { fs, sub } = this.route(a);
|
|
3181
|
+
return fs.isFile(sub);
|
|
3182
|
+
}
|
|
3183
|
+
async stat(path) {
|
|
3184
|
+
const a = this.abs(path);
|
|
3185
|
+
if (this.isMountAncestor(a)) return DIR_META;
|
|
3186
|
+
const { fs, sub } = this.route(a);
|
|
3187
|
+
return fs.stat(sub);
|
|
3188
|
+
}
|
|
3189
|
+
async readDir(path) {
|
|
3190
|
+
const a = this.abs(path);
|
|
3191
|
+
const { fs, sub } = this.route(a);
|
|
3192
|
+
const names = /* @__PURE__ */ new Set();
|
|
3193
|
+
let routedOk = false;
|
|
3194
|
+
try {
|
|
3195
|
+
for (const n of await fs.readDir(sub)) names.add(n);
|
|
3196
|
+
routedOk = true;
|
|
3197
|
+
} catch {
|
|
3198
|
+
}
|
|
3199
|
+
for (const n of this.mountChildrenOf(a)) names.add(n);
|
|
3200
|
+
if (!routedOk && !this.isMountAncestor(a)) throw new Error(`Directory not found: ${path}`);
|
|
3201
|
+
return [...names].sort();
|
|
3202
|
+
}
|
|
3203
|
+
};
|
|
3204
|
+
|
|
3205
|
+
// src/speculate.ts
|
|
3206
|
+
init_OverlayFilesystem();
|
|
3207
|
+
async function raceAttempts(base, n, run, score) {
|
|
3208
|
+
const attempts = await Promise.all(
|
|
3209
|
+
Array.from({ length: Math.max(1, n) }, async (_, index) => {
|
|
3210
|
+
const fs = new OverlayFilesystem(base);
|
|
3211
|
+
const result = await run(fs, index);
|
|
3212
|
+
const s = await score({ fs, result, index });
|
|
3213
|
+
return { index, fs, result, score: s };
|
|
3214
|
+
})
|
|
3215
|
+
);
|
|
3216
|
+
const ranked = attempts.filter((a) => a.score != null).sort((a, b) => b.score - a.score || a.index - b.index);
|
|
3217
|
+
const winner = ranked[0] ?? null;
|
|
3218
|
+
if (winner) await winner.fs.commit();
|
|
3219
|
+
return { winner, attempts };
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
// src/synthesize.ts
|
|
3223
|
+
function validateToolCode(code) {
|
|
3224
|
+
const DENY = [
|
|
3225
|
+
["process", /\bprocess\b/],
|
|
3226
|
+
["require", /\brequire\b/],
|
|
3227
|
+
["import", /\bimport\b/],
|
|
3228
|
+
["eval", /\beval\b/],
|
|
3229
|
+
["Function", /\bFunction\b/],
|
|
3230
|
+
["constructor", /\bconstructor\b/],
|
|
3231
|
+
["__proto__", /__proto__/],
|
|
3232
|
+
["prototype", /\bprototype\b/],
|
|
3233
|
+
["globalThis", /\bglobalThis\b/],
|
|
3234
|
+
["global", /\bglobal\b/],
|
|
3235
|
+
["window", /\bwindow\b/],
|
|
3236
|
+
["self", /\bself\b/],
|
|
3237
|
+
["Bun", /\bBun\b/],
|
|
3238
|
+
["Deno", /\bDeno\b/],
|
|
3239
|
+
["fetch", /\bfetch\b/],
|
|
3240
|
+
["XMLHttpRequest", /\bXMLHttpRequest\b/],
|
|
3241
|
+
["WebSocket", /\bWebSocket\b/],
|
|
3242
|
+
["child_process", /\bchild_process\b/],
|
|
3243
|
+
["node:", /node:/],
|
|
3244
|
+
["setTimeout", /\bsetTimeout\b/],
|
|
3245
|
+
["setInterval", /\bsetInterval\b/],
|
|
3246
|
+
["setImmediate", /\bsetImmediate\b/],
|
|
3247
|
+
["Atomics", /\bAtomics\b/],
|
|
3248
|
+
["SharedArrayBuffer", /\bSharedArrayBuffer\b/],
|
|
3249
|
+
["WebAssembly", /\bWebAssembly\b/],
|
|
3250
|
+
["Reflect", /\bReflect\b/],
|
|
3251
|
+
["Proxy", /\bProxy\b/],
|
|
3252
|
+
["Buffer", /\bBuffer\b/],
|
|
3253
|
+
["while(true)", /while\s*\(\s*true\s*\)/],
|
|
3254
|
+
["for(;;)", /for\s*\(\s*;\s*;\s*\)/],
|
|
3255
|
+
// escape sequences can smuggle a denied identifier past the substring check
|
|
3256
|
+
// (e.g. `process` parses as `process`), so forbid them outright.
|
|
3257
|
+
["\\u escape", /\\u/],
|
|
3258
|
+
["\\x escape", /\\x/]
|
|
3259
|
+
];
|
|
3260
|
+
const violations = DENY.filter(([, re]) => re.test(code)).map(([name]) => name);
|
|
3261
|
+
return { ok: violations.length === 0, violations };
|
|
3262
|
+
}
|
|
3263
|
+
function compileSynthesizedTool(spec) {
|
|
3264
|
+
const v = validateToolCode(spec.code);
|
|
3265
|
+
if (!v.ok) throw new Error(`unsafe synthesized tool '${spec.name}': blocked tokens [${v.violations.join(", ")}]`);
|
|
3266
|
+
if (!/^[A-Za-z_]\w{0,39}$/.test(spec.name)) throw new Error(`invalid tool name '${spec.name}'`);
|
|
3267
|
+
const fn = new Function("args", "ctx", `"use strict"; return (async (args, ctx) => { ${spec.code} })(args, ctx);`);
|
|
3268
|
+
return {
|
|
3269
|
+
name: spec.name,
|
|
3270
|
+
description: spec.description,
|
|
3271
|
+
parameters: spec.parameters ?? { type: "object", properties: {} },
|
|
3272
|
+
async run(args, ctx) {
|
|
3273
|
+
const r = await fn(args, ctx);
|
|
3274
|
+
return typeof r === "string" ? r : JSON.stringify(r ?? "");
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// src/FakeAIClient.ts
|
|
3280
|
+
var FakeAIClient = class {
|
|
3281
|
+
seen = [];
|
|
3282
|
+
script;
|
|
3283
|
+
constructor(script) {
|
|
3284
|
+
this.script = [...script];
|
|
3285
|
+
}
|
|
3286
|
+
async chat(options) {
|
|
3287
|
+
this.seen.push(options);
|
|
3288
|
+
const next = this.script.shift();
|
|
3289
|
+
if (!next) throw new Error("FakeAIClient: script exhausted (model asked for more turns than scripted)");
|
|
3290
|
+
return next;
|
|
3291
|
+
}
|
|
3292
|
+
};
|
|
3293
|
+
var toolCall = (id, name, args) => ({
|
|
3294
|
+
id,
|
|
3295
|
+
type: "function",
|
|
3296
|
+
function: { name, arguments: JSON.stringify(args) }
|
|
3297
|
+
});
|
|
3298
|
+
|
|
3299
|
+
// src/index.ts
|
|
3300
|
+
init_tools();
|
|
3301
|
+
|
|
3302
|
+
// src/presets.ts
|
|
3303
|
+
init_tools();
|
|
3304
|
+
import { MemFilesystem } from "@livx.cc/wcli/core";
|
|
3305
|
+
function sandboxAgentOptions(opts = {}) {
|
|
3306
|
+
return { fs: new MemFilesystem(), ...opts };
|
|
3307
|
+
}
|
|
3308
|
+
function diskAgentOptions(opts = {}) {
|
|
3309
|
+
return { ...opts };
|
|
3310
|
+
}
|
|
3311
|
+
async function fullAgentOptions(opts = {}) {
|
|
3312
|
+
const { makeRealShellTool: makeRealShellTool2, makeShellJobTools: makeShellJobTools2, ShellJobRegistry: ShellJobRegistry2 } = await Promise.resolve().then(() => (init_tools_shell(), tools_shell_exports));
|
|
3313
|
+
const { cwd = process.cwd(), tools, ...rest } = opts;
|
|
3314
|
+
const registry = new ShellJobRegistry2({ cwd, killOnExit: true });
|
|
3315
|
+
const shell = [makeRealShellTool2({ cwd, registry }), ...makeShellJobTools2(registry)];
|
|
3316
|
+
return { ...rest, tools: [...tools ?? defaultTools(), ...shell] };
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
// src/index.ts
|
|
3320
|
+
init_tools_structured();
|
|
3321
|
+
init_todo();
|
|
3322
|
+
init_tools_web();
|
|
3323
|
+
|
|
3324
|
+
// src/lessons.ts
|
|
3325
|
+
init_logging();
|
|
3326
|
+
var log5 = forComponent("Lessons");
|
|
3327
|
+
var LessonOptionsDefaults = class {
|
|
3328
|
+
minRepeats = 2;
|
|
3329
|
+
};
|
|
3330
|
+
var LESSONS = [
|
|
3331
|
+
{ match: /changed since it was read|stale/i, slug: "lesson-stale-edit", body: "Edit kept failing because the file changed since it was read (stale). Re-Read a file immediately before Editing it \u2014 do not Read far ahead of the Edit." },
|
|
3332
|
+
{ match: /is not unique|\(\d+ matches\)/i, slug: "lesson-edit-ambiguous", body: "Edit kept failing because old_string was not unique. Include more surrounding lines to disambiguate, or batch with MultiEdit." },
|
|
3333
|
+
{ match: /has not been read yet/i, slug: "lesson-read-before-edit", body: "Edit was attempted before Reading the file. Always Read a file before Editing it." },
|
|
3334
|
+
{ match: /not found in/i, slug: "lesson-edit-not-found", body: "Edit old_string was not found. Re-Read the current file and copy the exact text (whitespace included) before Editing." },
|
|
3335
|
+
{ match: /unknown tool/i, slug: "lesson-unknown-tool", body: "A non-existent tool was called. Only call tools that are advertised in the current tool set." }
|
|
3336
|
+
];
|
|
3337
|
+
var isFailure = (result) => /^Error:|^Blocked by hook:|^\[exit [1-9]/.test(result);
|
|
3338
|
+
function lessonCapture(options) {
|
|
3339
|
+
const o = { ...new LessonOptionsDefaults(), ...options };
|
|
3340
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3341
|
+
const written = /* @__PURE__ */ new Set();
|
|
3342
|
+
return {
|
|
3343
|
+
async postToolUse(_call, result) {
|
|
3344
|
+
if (!isFailure(result)) return;
|
|
3345
|
+
const lesson = LESSONS.find((l) => l.match.test(result));
|
|
3346
|
+
if (!lesson || written.has(lesson.slug)) return;
|
|
3347
|
+
const n = (counts.get(lesson.slug) ?? 0) + 1;
|
|
3348
|
+
counts.set(lesson.slug, n);
|
|
3349
|
+
if (n < o.minRepeats) return;
|
|
3350
|
+
written.add(lesson.slug);
|
|
3351
|
+
await writeFact(o.fs, o.dir, lesson.slug, lesson.body).catch((e) => log5.warn(`could not persist ${lesson.slug}: ${e?.message ?? e}`));
|
|
3352
|
+
log5.debug(`captured lesson ${lesson.slug} (recurred ${n}\xD7)`);
|
|
3353
|
+
}
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
// src/reflect.ts
|
|
3358
|
+
init_logging();
|
|
3359
|
+
var log6 = forComponent("Reflect");
|
|
3360
|
+
async function reflectOnRun(o) {
|
|
3361
|
+
const digest = digestRun(o.result.messages, o.maxDigestChars ?? 6e3);
|
|
3362
|
+
if (!digest.trim()) return null;
|
|
3363
|
+
const prompt = `A coding agent just finished a task with outcome "${o.result.finishReason}". Here is a digest of what it did:
|
|
3364
|
+
|
|
3365
|
+
${digest}
|
|
3366
|
+
|
|
3367
|
+
If there is a DURABLE, reusable lesson that would help a FUTURE session avoid a mistake seen here, reply with exactly one line:
|
|
3368
|
+
LESSON: <imperative, specific, \u2264200 chars>
|
|
3369
|
+
If the run was fine or the issue was purely task-specific (not generalizable), reply exactly: NONE`;
|
|
3370
|
+
let text = "";
|
|
3371
|
+
try {
|
|
3372
|
+
const r = await o.ai.chat({ model: o.model, messages: [{ role: "user", content: prompt }], stream: false });
|
|
3373
|
+
text = r?.content ?? "";
|
|
3374
|
+
} catch (e) {
|
|
3375
|
+
log6.warn(`reflection call failed: ${e?.message ?? e}`);
|
|
3376
|
+
return null;
|
|
3377
|
+
}
|
|
3378
|
+
const m = text.match(/LESSON:\s*(.+)/i);
|
|
3379
|
+
const lesson = m?.[1]?.trim().slice(0, 200) ?? "";
|
|
3380
|
+
if (!lesson || /^none\b/i.test(lesson)) return null;
|
|
3381
|
+
const slug = ("lesson-" + slugify(lesson)).slice(0, 56);
|
|
3382
|
+
try {
|
|
3383
|
+
await writeFact(o.fs, o.dir, slug, lesson);
|
|
3384
|
+
} catch (e) {
|
|
3385
|
+
log6.warn(`could not persist lesson: ${e?.message ?? e}`);
|
|
3386
|
+
return null;
|
|
3387
|
+
}
|
|
3388
|
+
log6.debug(`reflection persisted ${slug}`);
|
|
3389
|
+
return slug;
|
|
3390
|
+
}
|
|
3391
|
+
function digestRun(messages, maxChars) {
|
|
3392
|
+
const lines = [];
|
|
3393
|
+
for (const m of messages) {
|
|
3394
|
+
if (m.role === "assistant" && m.content) lines.push(`assistant: ${contentText(m.content).slice(0, 200)}`);
|
|
3395
|
+
for (const tc of m.tool_calls ?? []) lines.push(`tool ${tc.function.name}(${(tc.function.arguments ?? "").slice(0, 120)})`);
|
|
3396
|
+
if (m.role === "tool" && m.content) lines.push(` \u2192 ${contentText(m.content).split("\n")[0].slice(0, 200)}`);
|
|
3397
|
+
}
|
|
3398
|
+
const out = lines.join("\n").replace(/LESSON:/gi, "lesson(reported):");
|
|
3399
|
+
return out.length > maxChars ? out.slice(0, maxChars) + "\n\u2026 (truncated)" : out;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// src/mcp.ts
|
|
3403
|
+
function toText(result) {
|
|
3404
|
+
if (result == null) return "";
|
|
3405
|
+
if (typeof result === "string") return result;
|
|
3406
|
+
const content = result.content;
|
|
3407
|
+
if (Array.isArray(content)) {
|
|
3408
|
+
const text = content.map((c) => typeof c?.text === "string" ? c.text : JSON.stringify(c)).join("\n");
|
|
3409
|
+
if (text) return text;
|
|
3410
|
+
}
|
|
3411
|
+
return JSON.stringify(result);
|
|
3412
|
+
}
|
|
3413
|
+
function mcpToolToAgentTool(spec, callTool, prefix = "mcp__") {
|
|
3414
|
+
return {
|
|
3415
|
+
name: `${prefix}${spec.name}`,
|
|
3416
|
+
description: spec.description ?? `MCP tool ${spec.name}`,
|
|
3417
|
+
parameters: spec.inputSchema ?? { type: "object", properties: {} },
|
|
3418
|
+
async run(args, _ctx) {
|
|
3419
|
+
return toText(await callTool(spec.name, args ?? {}));
|
|
3420
|
+
}
|
|
3421
|
+
};
|
|
3422
|
+
}
|
|
3423
|
+
function mcpToolsToAgentTools(specs, callTool, prefix = "mcp__", filter) {
|
|
3424
|
+
return (filter ? specs.filter(filter) : specs).map((s) => mcpToolToAgentTool(s, callTool, prefix));
|
|
3425
|
+
}
|
|
3426
|
+
function describeSpec(s) {
|
|
3427
|
+
const schema = s.inputSchema ? `
|
|
3428
|
+
args: ${JSON.stringify(s.inputSchema)}` : "";
|
|
3429
|
+
return `${s.name} \u2014 ${s.description ?? "(no description)"}${schema}`;
|
|
3430
|
+
}
|
|
3431
|
+
function makeMcpToolSearch(specs, callTool, options = {}) {
|
|
3432
|
+
const maxResults = options.maxResults ?? 10;
|
|
3433
|
+
const byName = new Map(specs.map((s) => [s.name, s]));
|
|
3434
|
+
const catalogLine = `${specs.length} MCP tool(s) available \u2014 search by keyword, then call by exact name.`;
|
|
3435
|
+
const searchTool = {
|
|
3436
|
+
name: "ToolSearch",
|
|
3437
|
+
description: `Search the available MCP tools by keyword (${catalogLine}). Returns matching tool names + their argument schemas; call one with \`McpCall\`.`,
|
|
3438
|
+
parameters: { type: "object", required: ["query"], properties: { query: { type: "string", description: "keywords to match against tool name + description" } } },
|
|
3439
|
+
async run({ query }) {
|
|
3440
|
+
const q = String(query ?? "").trim();
|
|
3441
|
+
if (!q) return catalogLine;
|
|
3442
|
+
const { kept } = topByRelevance(specs, q, (s) => `${s.name} ${s.description ?? ""}`, maxResults);
|
|
3443
|
+
if (!kept.length) return `(no MCP tool matches "${q}" \u2014 try broader keywords)`;
|
|
3444
|
+
return kept.map(describeSpec).join("\n");
|
|
3445
|
+
}
|
|
3446
|
+
};
|
|
3447
|
+
const callMcpTool = {
|
|
3448
|
+
name: "McpCall",
|
|
3449
|
+
description: "Call an MCP tool discovered via `ToolSearch`, by its exact name. Pass its arguments as `args`.",
|
|
3450
|
+
parameters: {
|
|
3451
|
+
type: "object",
|
|
3452
|
+
required: ["name"],
|
|
3453
|
+
properties: {
|
|
3454
|
+
name: { type: "string", description: "exact tool name from ToolSearch" },
|
|
3455
|
+
args: { type: "object", description: "arguments object for the tool (per its schema)" }
|
|
3456
|
+
}
|
|
3457
|
+
},
|
|
3458
|
+
async run({ name, args }) {
|
|
3459
|
+
const n = String(name ?? "");
|
|
3460
|
+
if (!byName.has(n)) return `Error: unknown MCP tool '${n}'. Use ToolSearch to find valid names.`;
|
|
3461
|
+
return toText(await callTool(n, args ?? {}));
|
|
3462
|
+
}
|
|
3463
|
+
};
|
|
3464
|
+
return [searchTool, callMcpTool];
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
// src/hooks.ts
|
|
3468
|
+
var RecordingHooks = class {
|
|
3469
|
+
/** tool name -> reason; a matching preToolUse call is blocked with that reason. */
|
|
3470
|
+
constructor(blocks = {}) {
|
|
3471
|
+
this.blocks = blocks;
|
|
3472
|
+
}
|
|
3473
|
+
blocks;
|
|
3474
|
+
pre = [];
|
|
3475
|
+
post = [];
|
|
3476
|
+
stops = [];
|
|
3477
|
+
preToolUse(call, meta) {
|
|
3478
|
+
this.pre.push({ call, meta });
|
|
3479
|
+
const reason = this.blocks[call.name];
|
|
3480
|
+
if (reason != null) return { block: true, reason };
|
|
3481
|
+
}
|
|
3482
|
+
postToolUse(call, result, meta) {
|
|
3483
|
+
this.post.push({ call, result, meta });
|
|
3484
|
+
}
|
|
3485
|
+
onStop(finalText) {
|
|
3486
|
+
this.stops.push(finalText);
|
|
3487
|
+
}
|
|
3488
|
+
};
|
|
3489
|
+
var RecordingLifecycle = class {
|
|
3490
|
+
/** @param startContext injected at session start; @param rewrite maps a submitted prompt to a new one. */
|
|
3491
|
+
constructor(startContext, rewrite) {
|
|
3492
|
+
this.startContext = startContext;
|
|
3493
|
+
this.rewrite = rewrite;
|
|
3494
|
+
}
|
|
3495
|
+
startContext;
|
|
3496
|
+
rewrite;
|
|
3497
|
+
starts = 0;
|
|
3498
|
+
prompts = [];
|
|
3499
|
+
compactions = [];
|
|
3500
|
+
subagentStops = [];
|
|
3501
|
+
onSessionStart() {
|
|
3502
|
+
this.starts++;
|
|
3503
|
+
return this.startContext;
|
|
3504
|
+
}
|
|
3505
|
+
onUserPromptSubmit(text) {
|
|
3506
|
+
this.prompts.push(text);
|
|
3507
|
+
return this.rewrite?.(text);
|
|
3508
|
+
}
|
|
3509
|
+
onPreCompact(messages) {
|
|
3510
|
+
this.compactions.push(messages.length);
|
|
3511
|
+
}
|
|
3512
|
+
onSubagentStop(summary, info) {
|
|
3513
|
+
this.subagentStops.push({ summary, label: info?.label });
|
|
3514
|
+
}
|
|
3515
|
+
};
|
|
3516
|
+
|
|
3517
|
+
// src/index.ts
|
|
3518
|
+
init_logging();
|
|
3519
|
+
import { MemFilesystem as MemFilesystem2, IndexedDbFilesystem, CommandExecutor as CommandExecutor2, registerHeadlessCommands as registerHeadlessCommands2 } from "@livx.cc/wcli/core";
|
|
3520
|
+
export {
|
|
3521
|
+
Agent,
|
|
3522
|
+
AgentOptions,
|
|
3523
|
+
BodDbFilesystem,
|
|
3524
|
+
CommandExecutor2 as CommandExecutor,
|
|
3525
|
+
ConsoleHostBridge,
|
|
3526
|
+
DEFAULT_DENY,
|
|
3527
|
+
DEFAULT_MUTATING,
|
|
3528
|
+
FakeAIClient,
|
|
3529
|
+
IndexedDbFilesystem,
|
|
3530
|
+
JailOptions,
|
|
3531
|
+
JailedFilesystem,
|
|
3532
|
+
LessonOptionsDefaults,
|
|
3533
|
+
MemFilesystem2 as MemFilesystem,
|
|
3534
|
+
MountFilesystem,
|
|
3535
|
+
NodeDiskFilesystem,
|
|
3536
|
+
OverlayFilesystem,
|
|
3537
|
+
PermissionOptions,
|
|
3538
|
+
PermissionPolicy,
|
|
3539
|
+
RecordingHooks,
|
|
3540
|
+
RecordingLifecycle,
|
|
3541
|
+
SandboxJobRegistry,
|
|
3542
|
+
ScriptedHostBridge,
|
|
3543
|
+
applyEditsTool,
|
|
3544
|
+
askUserQuestionTool,
|
|
3545
|
+
bashTool,
|
|
3546
|
+
checkpointTool,
|
|
3547
|
+
checkpointTools,
|
|
3548
|
+
compileSynthesizedTool,
|
|
3549
|
+
composeHooks,
|
|
3550
|
+
contentText,
|
|
3551
|
+
defaultTools,
|
|
3552
|
+
diskAgentOptions,
|
|
3553
|
+
editTool,
|
|
3554
|
+
expandCommand,
|
|
3555
|
+
expandTemplate,
|
|
3556
|
+
forComponent,
|
|
3557
|
+
fullAgentOptions,
|
|
3558
|
+
globTool,
|
|
3559
|
+
grepTool,
|
|
3560
|
+
htmlToText,
|
|
3561
|
+
idfWeights,
|
|
3562
|
+
imagePart,
|
|
3563
|
+
lessonCapture,
|
|
3564
|
+
loadAgents,
|
|
3565
|
+
loadCommands,
|
|
3566
|
+
loadInstructions,
|
|
3567
|
+
loadMemory,
|
|
3568
|
+
loadSkills,
|
|
3569
|
+
log,
|
|
3570
|
+
makeContext,
|
|
3571
|
+
makeJobTools,
|
|
3572
|
+
makeMcpToolSearch,
|
|
3573
|
+
makeTaskBatchTool,
|
|
3574
|
+
makeTaskTool,
|
|
3575
|
+
makeWebFetchTool,
|
|
3576
|
+
makeWebSearchTool,
|
|
3577
|
+
mcpToolToAgentTool,
|
|
3578
|
+
mcpToolsToAgentTools,
|
|
3579
|
+
mkdirp,
|
|
3580
|
+
multiEditTool,
|
|
3581
|
+
planMode,
|
|
3582
|
+
raceAttempts,
|
|
3583
|
+
readTool,
|
|
3584
|
+
reflectOnRun,
|
|
3585
|
+
registerHeadlessCommands2 as registerHeadlessCommands,
|
|
3586
|
+
relevanceScore,
|
|
3587
|
+
repoIndex,
|
|
3588
|
+
repoMapTool,
|
|
3589
|
+
rollbackTool,
|
|
3590
|
+
sandboxAgentOptions,
|
|
3591
|
+
slugify,
|
|
3592
|
+
toWireTools,
|
|
3593
|
+
todoWriteTool,
|
|
3594
|
+
tokenize,
|
|
3595
|
+
toolCall,
|
|
3596
|
+
toolRegistry,
|
|
3597
|
+
toolsByName,
|
|
3598
|
+
topByRelevance,
|
|
3599
|
+
validateToolCode,
|
|
3600
|
+
webFetchTool,
|
|
3601
|
+
webSearchTool,
|
|
3602
|
+
writeFact,
|
|
3603
|
+
writeTool
|
|
3604
|
+
};
|
|
3605
|
+
//# sourceMappingURL=index.js.map
|