teapot-coding-agent 0.8.0 → 0.10.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/README.md +19 -9
- package/dist/agent/agent.js +320 -95
- package/dist/agent/llm.js +8 -2
- package/dist/agent/skills.js +13 -0
- package/dist/agent/tools.js +577 -24
- package/dist/bus.js +1 -1
- package/dist/master.js +34 -2
- package/dist/scheduler/cron.js +15 -0
- package/dist/server/api.js +40 -4
- package/package.json +3 -1
- package/public/assets/index-CIc04lSi.css +1 -0
- package/public/assets/index-CqCUyqTK.js +13 -0
- package/public/index.html +2 -2
- package/public/assets/index-DIs0rfrk.css +0 -1
- package/public/assets/index-JRXeHcww.js +0 -8
package/dist/agent/tools.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Tool specs are plain JSON-schema function definitions — provider-agnostic.
|
|
4
4
|
*/
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { promises as fs } from "node:fs";
|
|
6
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, } from "./skills.js";
|
|
8
|
+
import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
|
|
9
9
|
const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
10
10
|
const num = (v, fallback) => (typeof v === "number" ? v : fallback);
|
|
11
11
|
function clip(s, max) {
|
|
@@ -36,7 +36,7 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
36
36
|
});
|
|
37
37
|
let out = "";
|
|
38
38
|
let done = false;
|
|
39
|
-
let
|
|
39
|
+
let killReason = null;
|
|
40
40
|
const collect = (chunk) => {
|
|
41
41
|
if (out.length < ctx.maxOutputBytes)
|
|
42
42
|
out += chunk.toString("utf8");
|
|
@@ -53,11 +53,23 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
53
53
|
}
|
|
54
54
|
};
|
|
55
55
|
const timer = setTimeout(() => {
|
|
56
|
-
|
|
56
|
+
killReason = `TIMEOUT after ${timeoutMs}ms`;
|
|
57
57
|
killGroup();
|
|
58
58
|
}, timeoutMs);
|
|
59
|
+
// harness shutdown must not wait out a long-running command
|
|
60
|
+
const onAbort = () => {
|
|
61
|
+
killReason = "ABORTED (harness shutdown)";
|
|
62
|
+
killGroup();
|
|
63
|
+
};
|
|
64
|
+
if (ctx.signal) {
|
|
65
|
+
if (ctx.signal.aborted)
|
|
66
|
+
onAbort();
|
|
67
|
+
else
|
|
68
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
}
|
|
59
70
|
child.on("error", (err) => {
|
|
60
71
|
clearTimeout(timer);
|
|
72
|
+
ctx.signal?.removeEventListener("abort", onAbort);
|
|
61
73
|
if (!done) {
|
|
62
74
|
done = true;
|
|
63
75
|
resolve({ ok: false, result: `spawn error: ${err.message}` });
|
|
@@ -65,13 +77,14 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
65
77
|
});
|
|
66
78
|
child.on("close", (code, signal) => {
|
|
67
79
|
clearTimeout(timer);
|
|
80
|
+
ctx.signal?.removeEventListener("abort", onAbort);
|
|
68
81
|
if (done)
|
|
69
82
|
return;
|
|
70
83
|
done = true;
|
|
71
|
-
if (
|
|
84
|
+
if (killReason) {
|
|
72
85
|
resolve({
|
|
73
86
|
ok: false,
|
|
74
|
-
result:
|
|
87
|
+
result: `${killReason}. Partial output:\n${clip(out.trim() || "(no output)", ctx.maxOutputBytes)}`,
|
|
75
88
|
});
|
|
76
89
|
return;
|
|
77
90
|
}
|
|
@@ -83,17 +96,328 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
83
96
|
});
|
|
84
97
|
});
|
|
85
98
|
}
|
|
99
|
+
/** 1-based line number of each occurrence of needle in text. */
|
|
100
|
+
function matchLines(text, needle) {
|
|
101
|
+
const out = [];
|
|
102
|
+
let idx = text.indexOf(needle);
|
|
103
|
+
while (idx !== -1) {
|
|
104
|
+
out.push(text.slice(0, idx).split("\n").length);
|
|
105
|
+
idx = text.indexOf(needle, idx + Math.max(needle.length, 1));
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fuzzy-but-safe locator: find windows of lines equal to the pattern after
|
|
111
|
+
* trimming trailing whitespace on each side. Returns 0-based start line hits.
|
|
112
|
+
*/
|
|
113
|
+
function trailingWsMatches(srcLines, patLines) {
|
|
114
|
+
const hits = [];
|
|
115
|
+
for (let i = 0; i + patLines.length <= srcLines.length; i++) {
|
|
116
|
+
let ok = true;
|
|
117
|
+
for (let j = 0; j < patLines.length; j++) {
|
|
118
|
+
if (srcLines[i + j].trimEnd() !== patLines[j].trimEnd()) {
|
|
119
|
+
ok = false;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (ok)
|
|
124
|
+
hits.push(i);
|
|
125
|
+
}
|
|
126
|
+
return hits;
|
|
127
|
+
}
|
|
86
128
|
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
129
|
+
/* ---------- read_url cache ---------- */
|
|
130
|
+
const URL_CACHE_TTL_MS = 3_600_000;
|
|
131
|
+
const urlCache = new Map();
|
|
132
|
+
function clipText(s, max) {
|
|
133
|
+
const n = Math.max(1000, Math.min(max, 80_000));
|
|
134
|
+
return s.length <= n ? s : `${s.slice(0, n)}\n… [truncated, ${s.length} chars total]`;
|
|
135
|
+
}
|
|
136
|
+
function compileRegex(pattern, ignoreCase) {
|
|
137
|
+
try {
|
|
138
|
+
return new RegExp(pattern, ignoreCase ? "i" : "");
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
return `invalid regex: ${e.message}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Codex seek_sequence: find pattern lines at/after `start`, loosening match rules stepwise. */
|
|
145
|
+
function seekSequence(lines, pattern, start, eof) {
|
|
146
|
+
if (pattern.length === 0)
|
|
147
|
+
return start;
|
|
148
|
+
if (pattern.length > lines.length)
|
|
149
|
+
return null;
|
|
150
|
+
const searchStart = eof && lines.length >= pattern.length ? Math.max(start, lines.length - pattern.length) : start;
|
|
151
|
+
const eqExact = (a, b) => a === b;
|
|
152
|
+
const eqRstrip = (a, b) => a.trimEnd() === b.trimEnd();
|
|
153
|
+
const eqTrim = (a, b) => a.trim() === b.trim();
|
|
154
|
+
// typographic dashes/quotes/spaces → ASCII, mirroring codex's final pass
|
|
155
|
+
const normalise = (s) => s
|
|
156
|
+
.trim()
|
|
157
|
+
.replace(/[\u2010-\u2015\u2212]/g, "-")
|
|
158
|
+
.replace(/[\u2018-\u201B]/g, "'")
|
|
159
|
+
.replace(/[\u201C-\u201F]/g, '"')
|
|
160
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
161
|
+
const eqNorm = (a, b) => normalise(a) === normalise(b);
|
|
162
|
+
for (const eq of [eqExact, eqRstrip, eqTrim, eqNorm]) {
|
|
163
|
+
for (let i = searchStart; i + pattern.length <= lines.length; i++) {
|
|
164
|
+
let ok = true;
|
|
165
|
+
for (let j = 0; j < pattern.length; j++) {
|
|
166
|
+
if (!eq(lines[i + j], pattern[j])) {
|
|
167
|
+
ok = false;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (ok)
|
|
172
|
+
return i;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
function parsePatch(patch) {
|
|
178
|
+
let text = patch.trim();
|
|
179
|
+
// lenient: strip a heredoc wrapper (<<EOF … EOF), as models sometimes emit one
|
|
180
|
+
const lines0 = text.split("\n");
|
|
181
|
+
if (lines0.length >= 4 &&
|
|
182
|
+
["<<EOF", "<<'EOF'", '<<"EOF"'].includes(lines0[0].trim()) &&
|
|
183
|
+
lines0[lines0.length - 1].trimEnd().endsWith("EOF")) {
|
|
184
|
+
text = lines0.slice(1, -1).join("\n").trim();
|
|
185
|
+
}
|
|
186
|
+
const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
|
|
187
|
+
if (lines[0]?.trim() !== "*** Begin Patch")
|
|
188
|
+
return `invalid patch: The first line must be '*** Begin Patch'`;
|
|
189
|
+
if (lines[lines.length - 1]?.trim() !== "*** End Patch")
|
|
190
|
+
return `invalid patch: The last line must be '*** End Patch'`;
|
|
191
|
+
const ops = [];
|
|
192
|
+
let i = 1;
|
|
193
|
+
while (i < lines.length) {
|
|
194
|
+
const line = lines[i];
|
|
195
|
+
const t = line.trim();
|
|
196
|
+
if (t === "*** End Patch")
|
|
197
|
+
break;
|
|
198
|
+
if (!t || t.startsWith("*** Environment ID:")) {
|
|
199
|
+
i++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
let m = t.match(/^\*\*\* Add File: (.+)$/);
|
|
203
|
+
if (m) {
|
|
204
|
+
const body = [];
|
|
205
|
+
i++;
|
|
206
|
+
while (i < lines.length && lines[i].startsWith("+"))
|
|
207
|
+
body.push(lines[i++].slice(1));
|
|
208
|
+
if (body.length === 0)
|
|
209
|
+
return `invalid patch: Add File hunk for '${m[1].trim()}' has no + lines`;
|
|
210
|
+
ops.push({ kind: "add", path: m[1].trim(), contents: body.join("\n") + "\n" });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
m = t.match(/^\*\*\* Delete File: (.+)$/);
|
|
214
|
+
if (m) {
|
|
215
|
+
ops.push({ kind: "delete", path: m[1].trim() });
|
|
216
|
+
i++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
m = t.match(/^\*\*\* Update File: (.+)$/);
|
|
220
|
+
if (m) {
|
|
221
|
+
const filePath = m[1].trim();
|
|
222
|
+
let movePath = null;
|
|
223
|
+
i++;
|
|
224
|
+
const mv = lines[i]?.trim().match(/^\*\*\* Move to: (.+)$/);
|
|
225
|
+
if (mv) {
|
|
226
|
+
movePath = mv[1].trim();
|
|
227
|
+
i++;
|
|
228
|
+
}
|
|
229
|
+
const chunks = [];
|
|
230
|
+
let cur = null;
|
|
231
|
+
const flush = () => {
|
|
232
|
+
if (cur)
|
|
233
|
+
chunks.push(cur);
|
|
234
|
+
cur = null;
|
|
235
|
+
};
|
|
236
|
+
while (i < lines.length && !lines[i].trim().startsWith("*** ")) {
|
|
237
|
+
const l = lines[i];
|
|
238
|
+
if (l.startsWith("@@")) {
|
|
239
|
+
flush();
|
|
240
|
+
cur = { changeContext: l.slice(2).trim() || null, oldLines: [], newLines: [], isEndOfFile: false };
|
|
241
|
+
i++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (l.startsWith("+") || l.startsWith("-") || l.startsWith(" ")) {
|
|
245
|
+
cur ??= { changeContext: null, oldLines: [], newLines: [], isEndOfFile: false }; // implicit first chunk
|
|
246
|
+
if (l.startsWith("+"))
|
|
247
|
+
cur.newLines.push(l.slice(1));
|
|
248
|
+
else if (l.startsWith("-"))
|
|
249
|
+
cur.oldLines.push(l.slice(1));
|
|
250
|
+
else {
|
|
251
|
+
cur.oldLines.push(l.slice(1));
|
|
252
|
+
cur.newLines.push(l.slice(1));
|
|
253
|
+
}
|
|
254
|
+
i++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (l.trim() === "*** End of File") {
|
|
258
|
+
if (!cur)
|
|
259
|
+
return `invalid patch: *** End of File outside a hunk in '${filePath}'`;
|
|
260
|
+
cur.isEndOfFile = true;
|
|
261
|
+
i++;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!l.trim()) {
|
|
265
|
+
i++;
|
|
266
|
+
continue; // blank between hunks
|
|
267
|
+
}
|
|
268
|
+
return `invalid patch: bad line in Update File '${filePath}': "${l.slice(0, 60)}" (expected ' ', '-', '+' or '@@')`;
|
|
269
|
+
}
|
|
270
|
+
flush();
|
|
271
|
+
if (chunks.length === 0)
|
|
272
|
+
return `invalid patch: Update File hunk for path '${filePath}' is empty`;
|
|
273
|
+
ops.push({ kind: "update", path: filePath, movePath, chunks });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
return `invalid patch: unrecognized directive "${t.slice(0, 60)}"`;
|
|
277
|
+
}
|
|
278
|
+
return ops;
|
|
279
|
+
}
|
|
280
|
+
/** Compute the updated content of one file (no I/O writes). Error string on failure. */
|
|
281
|
+
async function deriveUpdate(p, displayPath, chunks) {
|
|
282
|
+
let raw;
|
|
283
|
+
try {
|
|
284
|
+
raw = await fs.readFile(p, "utf8");
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return `Failed to read file to update ${displayPath}`;
|
|
288
|
+
}
|
|
289
|
+
const hadCrlf = raw.includes("\r\n");
|
|
290
|
+
const originalLines = raw.split("\n");
|
|
291
|
+
if (originalLines.at(-1) === "")
|
|
292
|
+
originalLines.pop(); // trailing newline → diff-standard line list
|
|
293
|
+
const replacements = [];
|
|
294
|
+
let lineIndex = 0;
|
|
295
|
+
for (const ch of chunks) {
|
|
296
|
+
if (ch.changeContext != null) {
|
|
297
|
+
const idx = seekSequence(originalLines, [ch.changeContext], lineIndex, false);
|
|
298
|
+
if (idx == null)
|
|
299
|
+
return `Failed to find context '${ch.changeContext}' in ${displayPath}`;
|
|
300
|
+
lineIndex = idx + 1;
|
|
301
|
+
}
|
|
302
|
+
let pattern = ch.oldLines;
|
|
303
|
+
let newSlice = ch.newLines;
|
|
304
|
+
if (pattern.length === 0) {
|
|
305
|
+
// codex semantics: a chunk with no context/removed lines appends at end of file
|
|
306
|
+
replacements.push([originalLines.length, 0, newSlice]);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
let found = seekSequence(originalLines, pattern, lineIndex, ch.isEndOfFile);
|
|
310
|
+
if (found == null && pattern.at(-1) === "") {
|
|
311
|
+
// trailing "" usually represents the file's final newline sentinel
|
|
312
|
+
const p2 = pattern.slice(0, -1);
|
|
313
|
+
const n2 = newSlice.at(-1) === "" ? newSlice.slice(0, -1) : newSlice;
|
|
314
|
+
found = seekSequence(originalLines, p2, lineIndex, ch.isEndOfFile);
|
|
315
|
+
if (found != null) {
|
|
316
|
+
pattern = p2;
|
|
317
|
+
newSlice = n2;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (found == null)
|
|
321
|
+
return (`Failed to find expected lines in ${displayPath}:\n${pattern.join("\n")}\n` +
|
|
322
|
+
`(re-read the file and regenerate the patch)`);
|
|
323
|
+
replacements.push([found, pattern.length, newSlice]);
|
|
324
|
+
lineIndex = found + pattern.length;
|
|
325
|
+
}
|
|
326
|
+
replacements.sort((a, b) => b[0] - a[0]); // descending so earlier edits keep indices valid
|
|
327
|
+
const out = originalLines.slice();
|
|
328
|
+
for (const [startIdx, oldLen, seg] of replacements)
|
|
329
|
+
out.splice(startIdx, oldLen, ...seg);
|
|
330
|
+
if (out.at(-1) !== "")
|
|
331
|
+
out.push("");
|
|
332
|
+
return { content: out.join("\n"), note: hadCrlf ? " (CRLF→LF)" : "" };
|
|
333
|
+
}
|
|
334
|
+
async function applyPatch(patch, ctx) {
|
|
335
|
+
const ops = parsePatch(patch);
|
|
336
|
+
if (typeof ops === "string")
|
|
337
|
+
return { ok: false, result: ops };
|
|
338
|
+
if (ops.length === 0)
|
|
339
|
+
return { ok: false, result: "patch contains no file operations" };
|
|
340
|
+
// resolve every path up front (workspace confinement + duplicate guard)
|
|
341
|
+
const seen = new Set();
|
|
342
|
+
const resolved = [];
|
|
343
|
+
try {
|
|
344
|
+
for (const op of ops) {
|
|
345
|
+
const abs = safeJoin(ctx.cwd, op.path);
|
|
346
|
+
if (seen.has(abs))
|
|
347
|
+
return { ok: false, result: `path touched twice in one patch: ${op.path}` };
|
|
348
|
+
seen.add(abs);
|
|
349
|
+
const r = { op, abs };
|
|
350
|
+
if (op.kind === "update" && op.movePath) {
|
|
351
|
+
r.absMove = safeJoin(ctx.cwd, op.movePath);
|
|
352
|
+
if (r.absMove === abs)
|
|
353
|
+
return { ok: false, result: `Move to: destination equals source (${op.path})` };
|
|
354
|
+
seen.add(r.absMove);
|
|
355
|
+
}
|
|
356
|
+
resolved.push(r);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
return { ok: false, result: e.message };
|
|
361
|
+
}
|
|
362
|
+
// phase 1 — validate everything, write nothing
|
|
363
|
+
const writes = [];
|
|
364
|
+
const deletes = [];
|
|
365
|
+
const summary = [];
|
|
366
|
+
try {
|
|
367
|
+
for (const { op, abs, absMove } of resolved) {
|
|
368
|
+
if (op.kind === "add") {
|
|
369
|
+
if (existsSync(abs))
|
|
370
|
+
return { ok: false, result: `Add File: ${op.path} already exists` };
|
|
371
|
+
writes.push({ abs, content: op.contents });
|
|
372
|
+
summary.push(`A ${op.path} (+${op.contents.split("\n").length - 1})`);
|
|
373
|
+
}
|
|
374
|
+
else if (op.kind === "delete") {
|
|
375
|
+
if (!existsSync(abs))
|
|
376
|
+
return { ok: false, result: `Delete File: ${op.path} not found` };
|
|
377
|
+
deletes.push(abs);
|
|
378
|
+
summary.push(`D ${op.path}`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
const r = await deriveUpdate(abs, op.path, op.chunks);
|
|
382
|
+
if (typeof r === "string")
|
|
383
|
+
return { ok: false, result: r };
|
|
384
|
+
const dest = absMove ?? abs;
|
|
385
|
+
if (absMove && existsSync(absMove))
|
|
386
|
+
return { ok: false, result: `Move to: destination already exists (${op.movePath})` };
|
|
387
|
+
writes.push({ abs: dest, content: r.content });
|
|
388
|
+
if (absMove)
|
|
389
|
+
deletes.push(abs);
|
|
390
|
+
summary.push(`${absMove ? "R" : "U"} ${op.path}${absMove ? ` → ${op.movePath}` : ""} (${op.chunks.length} hunk${op.chunks.length > 1 ? "s" : ""})${r.note}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch (e) {
|
|
395
|
+
return { ok: false, result: `patch validation failed: ${e.message}` };
|
|
396
|
+
}
|
|
397
|
+
// phase 2 — commit
|
|
398
|
+
for (const w of writes) {
|
|
399
|
+
await fs.mkdir(path.dirname(w.abs), { recursive: true });
|
|
400
|
+
await fs.writeFile(w.abs, w.content, "utf8");
|
|
401
|
+
}
|
|
402
|
+
for (const d of deletes)
|
|
403
|
+
await fs.rm(d).catch(() => { });
|
|
404
|
+
return { ok: true, result: `patch applied:\n${summary.join("\n")}` };
|
|
405
|
+
}
|
|
87
406
|
export const TOOLS = [
|
|
88
407
|
{
|
|
89
408
|
name: "read_file",
|
|
90
|
-
description: "Read a text file from the workspace.
|
|
409
|
+
description: "Read a text file from the workspace. Returns numbered lines (`N| ` prefixes are display-only — never copy them into edit_file). " +
|
|
410
|
+
"With `pattern`, acts like grep: only matching lines (JS regex, optional `ignore_case`) plus `context` surrounding lines are returned. " +
|
|
411
|
+
"A negative `offset` counts from the end (-30 → last 30 lines, or last 30 matches in pattern mode).",
|
|
91
412
|
parameters: {
|
|
92
413
|
type: "object",
|
|
93
414
|
properties: {
|
|
94
415
|
path: { type: "string", description: "Path relative to workspace root" },
|
|
95
|
-
offset: { type: "number", description: "1-indexed start line" },
|
|
96
|
-
limit: { type: "number", description: "Max lines
|
|
416
|
+
offset: { type: "number", description: "1-indexed start line (negative = from end)" },
|
|
417
|
+
limit: { type: "number", description: "Max lines (or max matches in pattern mode)" },
|
|
418
|
+
pattern: { type: "string", description: "JS regex — return only matching lines (+context) instead of the whole file" },
|
|
419
|
+
context: { type: "number", description: "context lines around each pattern match (max 5)" },
|
|
420
|
+
ignore_case: { type: "boolean", description: "case-insensitive pattern matching" },
|
|
97
421
|
},
|
|
98
422
|
required: ["path"],
|
|
99
423
|
},
|
|
@@ -101,7 +425,41 @@ export const TOOLS = [
|
|
|
101
425
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
102
426
|
const text = await readText(p);
|
|
103
427
|
const lines = text.split("\n");
|
|
104
|
-
|
|
428
|
+
// grep mode
|
|
429
|
+
if (typeof args.pattern === "string" && args.pattern !== "") {
|
|
430
|
+
const re = compileRegex(args.pattern, args.ignore_case === true);
|
|
431
|
+
if (typeof re === "string")
|
|
432
|
+
return { ok: false, result: re };
|
|
433
|
+
const idxs = [];
|
|
434
|
+
for (let i = 0; i < lines.length; i++)
|
|
435
|
+
if (re.test(lines[i]))
|
|
436
|
+
idxs.push(i);
|
|
437
|
+
if (idxs.length === 0)
|
|
438
|
+
return { ok: true, result: `(no matches for /${args.pattern}/)` };
|
|
439
|
+
let off = num(args.offset, 1);
|
|
440
|
+
off = off < 0 ? Math.max(0, idxs.length + off) : Math.max(0, off - 1);
|
|
441
|
+
const lim = Math.min(num(args.limit, 100), 1000);
|
|
442
|
+
const page = idxs.slice(off, off + lim);
|
|
443
|
+
const cN = Math.max(0, Math.min(num(args.context, 0), 5));
|
|
444
|
+
const regions = [];
|
|
445
|
+
for (const m of page) {
|
|
446
|
+
const s = Math.max(0, m - cN);
|
|
447
|
+
const e = Math.min(lines.length - 1, m + cN);
|
|
448
|
+
const last = regions[regions.length - 1];
|
|
449
|
+
if (last && s <= last[1] + 1)
|
|
450
|
+
last[1] = Math.max(last[1], e);
|
|
451
|
+
else
|
|
452
|
+
regions.push([s, e]);
|
|
453
|
+
}
|
|
454
|
+
const parts = regions.map(([s, e]) => lines.slice(s, e + 1).map((l, k) => `${s + k + 1}| ${l}`).join("\n"));
|
|
455
|
+
let result = parts.join("\n--\n");
|
|
456
|
+
if (idxs.length > page.length || off > 0)
|
|
457
|
+
result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
|
|
458
|
+
return { ok: true, result };
|
|
459
|
+
}
|
|
460
|
+
// plain mode
|
|
461
|
+
let off = num(args.offset, 1);
|
|
462
|
+
off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
|
|
105
463
|
const lim = num(args.limit, 2000);
|
|
106
464
|
const slice = lines.slice(off, off + lim).map((l, i) => `${off + i + 1}| ${l}`);
|
|
107
465
|
const more = off + lim < lines.length ? `\n... (${lines.length - off - lim} more lines)` : "";
|
|
@@ -110,7 +468,9 @@ export const TOOLS = [
|
|
|
110
468
|
},
|
|
111
469
|
{
|
|
112
470
|
name: "write_file",
|
|
113
|
-
description: "Create or
|
|
471
|
+
description: "Create ONE new file, or replace a file's entire content (parent dirs auto-created). " +
|
|
472
|
+
"Creating files as part of a larger batch of edits → one apply_patch instead. " +
|
|
473
|
+
"Partial changes to an existing file → edit_file.",
|
|
114
474
|
parameters: {
|
|
115
475
|
type: "object",
|
|
116
476
|
properties: {
|
|
@@ -128,13 +488,17 @@ export const TOOLS = [
|
|
|
128
488
|
},
|
|
129
489
|
{
|
|
130
490
|
name: "edit_file",
|
|
131
|
-
description: "
|
|
491
|
+
description: "Make exactly ONE small, unique replacement in one existing file — the cheapest tool for a single spot change. " +
|
|
492
|
+
"Copy old_text from the file contents (NOT from read_file's `N| ` prefixed display); it must appear exactly once — " +
|
|
493
|
+
"if it matches several places, add surrounding lines or pass replace_all=true. " +
|
|
494
|
+
"Two or more changes (or a rename/delete) → use apply_patch instead.",
|
|
132
495
|
parameters: {
|
|
133
496
|
type: "object",
|
|
134
497
|
properties: {
|
|
135
498
|
path: { type: "string" },
|
|
136
499
|
old_text: { type: "string" },
|
|
137
500
|
new_text: { type: "string" },
|
|
501
|
+
replace_all: { type: "boolean", description: "replace every occurrence instead of requiring uniqueness" },
|
|
138
502
|
},
|
|
139
503
|
required: ["path", "old_text", "new_text"],
|
|
140
504
|
},
|
|
@@ -143,24 +507,91 @@ export const TOOLS = [
|
|
|
143
507
|
let text = await readText(p);
|
|
144
508
|
const oldText = str(args.old_text);
|
|
145
509
|
const newText = str(args.new_text);
|
|
510
|
+
if (!oldText)
|
|
511
|
+
return { ok: false, result: "old_text is required" };
|
|
512
|
+
const replaceAll = args.replace_all === true;
|
|
146
513
|
let count = text.split(oldText).length - 1;
|
|
147
514
|
let normalized = false;
|
|
148
515
|
// tolerate LF patterns against CRLF files (convert once, on success)
|
|
149
516
|
if (count === 0 && oldText.includes("\n") && text.includes("\r\n")) {
|
|
150
517
|
const lf = text.replace(/\r\n/g, "\n");
|
|
151
518
|
const lfCount = lf.split(oldText).length - 1;
|
|
152
|
-
if (lfCount
|
|
519
|
+
if (lfCount >= 1) {
|
|
153
520
|
text = lf;
|
|
154
|
-
count =
|
|
521
|
+
count = lfCount;
|
|
155
522
|
normalized = true;
|
|
156
523
|
}
|
|
157
524
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (count
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
525
|
+
// tolerate patterns whose only difference is trailing whitespace per line
|
|
526
|
+
// (the most common near-miss) — applied only when it resolves uniquely
|
|
527
|
+
if (count === 0) {
|
|
528
|
+
const srcLines = text.replace(/\r\n/g, "\n").split("\n");
|
|
529
|
+
const patLines = oldText.replace(/\r\n/g, "\n").split("\n");
|
|
530
|
+
const hits = trailingWsMatches(srcLines, patLines);
|
|
531
|
+
if (hits.length === 1 && patLines.length > 0) {
|
|
532
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
533
|
+
const rebuilt = [
|
|
534
|
+
...srcLines.slice(0, hits[0]),
|
|
535
|
+
...newText.replace(/\r\n/g, "\n").split("\n"),
|
|
536
|
+
...srcLines.slice(hits[0] + patLines.length),
|
|
537
|
+
].join(eol);
|
|
538
|
+
await fs.writeFile(p, rebuilt, "utf8");
|
|
539
|
+
return {
|
|
540
|
+
ok: true,
|
|
541
|
+
result: `edited (matched ignoring trailing whitespace around ${path.basename(str(args.path))}:${hits[0] + 1})`,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (hits.length > 1)
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
result: `old_text matched ${hits.length} times ignoring trailing whitespace (lines ${hits.map((h) => h + 1).join(", ")}); add surrounding lines to disambiguate`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (count === 0) {
|
|
551
|
+
// actionable miss: point the model at recovery instead of pushing it
|
|
552
|
+
// toward bash-based editing
|
|
553
|
+
const lines = matchLines(text, oldText.trim());
|
|
554
|
+
const hint = lines.length
|
|
555
|
+
? `A trimmed variant appears at line(s) ${lines.slice(0, 5).join(", ")}.`
|
|
556
|
+
: `No similar text found — re-read ${str(args.path)} around the target area and copy old_text exactly.`;
|
|
557
|
+
return {
|
|
558
|
+
ok: false,
|
|
559
|
+
result: `old_text not found in file. ${hint} Watch indentation/trailing spaces and drop the \`N| \` line-number prefixes.`,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
if (count > 1 && !replaceAll) {
|
|
563
|
+
const at = matchLines(text, oldText);
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
result: `old_text matched ${count} times (lines ${at.slice(0, 5).join(", ")}) and must be unique — add surrounding lines to old_text, or pass replace_all=true`,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
await fs.writeFile(p, replaceAll ? text.split(oldText).join(newText) : text.replace(oldText, newText), "utf8");
|
|
570
|
+
const where = count > 1 ? ` (${count} occurrences)` : "";
|
|
571
|
+
return {
|
|
572
|
+
ok: true,
|
|
573
|
+
result: `${replaceAll ? "replaced all" : "edited"}${where}${normalized ? " (file converted CRLF→LF)" : ""}`,
|
|
574
|
+
};
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
name: "apply_patch",
|
|
579
|
+
description: "Apply a Codex-style patch: several edits in one file, changes across MULTIPLE files, renames, deletes — " +
|
|
580
|
+
"all validated first and applied atomically (any failure → nothing is written). Reach for this whenever " +
|
|
581
|
+
"one edit_file call would not cover the change. Every hunk is located with whitespace-tolerant fallbacks. Format:\n" +
|
|
582
|
+
'*** Begin Patch\n*** Add File: rel/new.txt\n+created line\n*** Update File: src/app.py\n@@ def main():\n context line\n-old line\n+new line\n*** Move to: src/main.py\n*** Delete File: obsolete.txt\n*** End Patch\n' +
|
|
583
|
+
"Update hunks: lines prefixed ' ' are context, '-' removed, '+' added. '@@ hint' optionally locates the area first; " +
|
|
584
|
+
"several hunks apply top-to-bottom. A hunk with only + lines appends at end of file; " +
|
|
585
|
+
"'*** End of File' anchors a hunk at the tail. For a single tiny replacement, edit_file is cheaper.",
|
|
586
|
+
parameters: {
|
|
587
|
+
type: "object",
|
|
588
|
+
properties: {
|
|
589
|
+
patch: { type: "string", description: "the full *** Begin Patch … *** End Patch text" },
|
|
590
|
+
},
|
|
591
|
+
required: ["patch"],
|
|
592
|
+
},
|
|
593
|
+
async run(args, ctx) {
|
|
594
|
+
return applyPatch(str(args.patch), ctx);
|
|
164
595
|
},
|
|
165
596
|
},
|
|
166
597
|
{
|
|
@@ -181,7 +612,9 @@ export const TOOLS = [
|
|
|
181
612
|
},
|
|
182
613
|
{
|
|
183
614
|
name: "bash",
|
|
184
|
-
description: "Run a bash command inside the workspace
|
|
615
|
+
description: "Run a bash command inside the workspace — git, builds, tests, searches and other COMMANDS. " +
|
|
616
|
+
"Also fine for quick shell-style file edits (sed/awk bulk transforms) when that is genuinely the better tool; " +
|
|
617
|
+
"for most changes the file tools below are easier to get right (no quoting, validated before writing). " +
|
|
185
618
|
"Killed (whole process group) on timeout. stdout+stderr are returned.",
|
|
186
619
|
parameters: {
|
|
187
620
|
type: "object",
|
|
@@ -195,6 +628,77 @@ export const TOOLS = [
|
|
|
195
628
|
return runShell(str(args.command), ctx, Math.min(num(args.timeout_ms, ctx.defaultTimeoutMs), 600_000));
|
|
196
629
|
},
|
|
197
630
|
},
|
|
631
|
+
{
|
|
632
|
+
name: "read_url",
|
|
633
|
+
description: "Fetch a web page and return its main readable content (title + plain text, boilerplate stripped via " +
|
|
634
|
+
"Mozilla Readability) — documentation, articles, issue threads. Cached for an hour per URL. " +
|
|
635
|
+
"For raw JSON/API responses or file downloads prefer bash curl.",
|
|
636
|
+
parameters: {
|
|
637
|
+
type: "object",
|
|
638
|
+
properties: {
|
|
639
|
+
url: { type: "string", description: "absolute http(s) URL" },
|
|
640
|
+
limit: { type: "number", description: "max characters returned (default 20000)" },
|
|
641
|
+
},
|
|
642
|
+
required: ["url"],
|
|
643
|
+
},
|
|
644
|
+
async run(args) {
|
|
645
|
+
const raw = str(args.url);
|
|
646
|
+
if (!URL.canParse(raw))
|
|
647
|
+
return { ok: false, result: `invalid url: ${raw.slice(0, 200)}` };
|
|
648
|
+
const u = new URL(raw);
|
|
649
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
650
|
+
return { ok: false, result: `unsupported protocol: ${u.protocol}` };
|
|
651
|
+
const key = u.toString();
|
|
652
|
+
const cached = urlCache.get(key);
|
|
653
|
+
if (cached && Date.now() - cached.at < URL_CACHE_TTL_MS)
|
|
654
|
+
return { ok: true, result: clipText(cached.text, num(args.limit, 20_000)) };
|
|
655
|
+
let res;
|
|
656
|
+
try {
|
|
657
|
+
res = await fetch(u, {
|
|
658
|
+
redirect: "follow",
|
|
659
|
+
signal: AbortSignal.timeout(45_000),
|
|
660
|
+
headers: { "user-agent": "Mozilla/5.0 (compatible; teapot-coding-agent)" },
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
catch (e) {
|
|
664
|
+
return { ok: false, result: `fetch failed: ${e.message}` };
|
|
665
|
+
}
|
|
666
|
+
const html = await res.text();
|
|
667
|
+
if (!html.trim())
|
|
668
|
+
return { ok: false, result: `HTTP ${res.status} with an empty body` };
|
|
669
|
+
// heavy DOM deps are loaded lazily so the master's idle startup stays lean
|
|
670
|
+
const { Browser } = await import("happy-dom");
|
|
671
|
+
const { Readability } = await import("@mozilla/readability");
|
|
672
|
+
const browser = new Browser();
|
|
673
|
+
let text = "";
|
|
674
|
+
try {
|
|
675
|
+
const page = browser.newPage();
|
|
676
|
+
page.url = key;
|
|
677
|
+
page.content = html;
|
|
678
|
+
const article = new Readability(page.mainFrame.document).parse();
|
|
679
|
+
text =
|
|
680
|
+
[article?.title, article?.byline]
|
|
681
|
+
.filter(Boolean)
|
|
682
|
+
.join(" — ") + `\n(HTTP ${res.status}, ~${(article?.textContent ?? "").length} chars extracted)\n\n` +
|
|
683
|
+
(article?.textContent ?? page.mainFrame.document.body?.textContent ?? "").replace(/\n{3,}/g, "\n\n").trim();
|
|
684
|
+
}
|
|
685
|
+
catch (e) {
|
|
686
|
+
return { ok: false, result: `failed to parse page: ${e.message}` };
|
|
687
|
+
}
|
|
688
|
+
finally {
|
|
689
|
+
await browser.close().catch(() => { });
|
|
690
|
+
}
|
|
691
|
+
if (res.ok && text.trim()) {
|
|
692
|
+
if (urlCache.size >= 40) {
|
|
693
|
+
const oldest = [...urlCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
694
|
+
if (oldest)
|
|
695
|
+
urlCache.delete(oldest[0]);
|
|
696
|
+
}
|
|
697
|
+
urlCache.set(key, { at: Date.now(), text });
|
|
698
|
+
}
|
|
699
|
+
return { ok: res.ok || text.length > 0, result: clipText(text, num(args.limit, 20_000)) };
|
|
700
|
+
},
|
|
701
|
+
},
|
|
198
702
|
{
|
|
199
703
|
name: "load_skill",
|
|
200
704
|
description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
|
|
@@ -216,20 +720,50 @@ export const TOOLS = [
|
|
|
216
720
|
result: `unknown skill: ${str(args.name)}. Available: ${skills.map((s) => s.name).join(", ") || "(none)"}`,
|
|
217
721
|
};
|
|
218
722
|
}
|
|
219
|
-
|
|
723
|
+
let result = await readSkillFile(def);
|
|
724
|
+
if (def.files.length) {
|
|
725
|
+
// surface bundled scripts as runnable paths: workspace-relative when
|
|
726
|
+
// possible (bash runs in the workspace), absolute for global skills
|
|
727
|
+
const dirAbs = path.dirname(def.filePath);
|
|
728
|
+
const rootDir = roots
|
|
729
|
+
.map((r) => r.dir)
|
|
730
|
+
.find((d) => def.filePath.startsWith(d + path.sep));
|
|
731
|
+
const listed = def.files.map((f) => {
|
|
732
|
+
const abs = path.join(dirAbs, f);
|
|
733
|
+
return rootDir ? path.relative(ctx.cwd, abs) || f : abs;
|
|
734
|
+
});
|
|
735
|
+
result +=
|
|
736
|
+
`\n\n--- Files bundled with this skill:\n` +
|
|
737
|
+
listed.map((f) => `- ${f}`).join("\n") +
|
|
738
|
+
`\nRun scripts with bash (chmod +x first if needed).`;
|
|
739
|
+
}
|
|
740
|
+
return { ok: true, result };
|
|
220
741
|
},
|
|
221
742
|
},
|
|
222
743
|
{
|
|
223
744
|
name: "save_skill",
|
|
224
745
|
description: "Create or update a reusable skill (a playbook you want to survive this session and be " +
|
|
225
746
|
"loadable later via load_skill). Write distilled, step-by-step instructions — not a chat log. " +
|
|
226
|
-
"
|
|
747
|
+
"Bundle helper scripts with the files argument; they are saved NEXT TO SKILL.md, listed by " +
|
|
748
|
+
"load_skill, and made executable (.sh/.py/.js). Available from the next turn.",
|
|
227
749
|
parameters: {
|
|
228
750
|
type: "object",
|
|
229
751
|
properties: {
|
|
230
752
|
name: { type: "string", description: "kebab-case id, e.g. release-checklist" },
|
|
231
753
|
description: { type: "string", description: "one line: what it is for / when to use it" },
|
|
232
754
|
content: { type: "string", description: "markdown instructions" },
|
|
755
|
+
files: {
|
|
756
|
+
type: "array",
|
|
757
|
+
description: "helper scripts/files stored beside SKILL.md",
|
|
758
|
+
items: {
|
|
759
|
+
type: "object",
|
|
760
|
+
properties: {
|
|
761
|
+
name: { type: "string", description: 'bare file name, e.g. "rollback.sh"' },
|
|
762
|
+
content: { type: "string" },
|
|
763
|
+
},
|
|
764
|
+
required: ["name", "content"],
|
|
765
|
+
},
|
|
766
|
+
},
|
|
233
767
|
},
|
|
234
768
|
required: ["name", "description", "content"],
|
|
235
769
|
},
|
|
@@ -247,7 +781,24 @@ export const TOOLS = [
|
|
|
247
781
|
if (!content.trim())
|
|
248
782
|
return { ok: false, result: "content required" };
|
|
249
783
|
const filePath = await saveSkill(roots[0].dir, name, description, content.slice(0, 64_000));
|
|
250
|
-
|
|
784
|
+
// bundled scripts/files next to SKILL.md
|
|
785
|
+
const written = [];
|
|
786
|
+
const rawFiles = Array.isArray(args.files) ? args.files : [];
|
|
787
|
+
for (const f of rawFiles.slice(0, 10)) {
|
|
788
|
+
const fname = str(f?.name);
|
|
789
|
+
const fcontent = str(f?.content);
|
|
790
|
+
if (!fcontent.trim())
|
|
791
|
+
continue;
|
|
792
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(fname) || fname === SKILL_FILE)
|
|
793
|
+
continue;
|
|
794
|
+
const abs = path.join(path.dirname(filePath), fname);
|
|
795
|
+
await fs.writeFile(abs, fcontent, "utf8");
|
|
796
|
+
if (/\.(sh|py|js|mjs)$/.test(fname))
|
|
797
|
+
await fs.chmod(abs, 0o755);
|
|
798
|
+
written.push(fname);
|
|
799
|
+
}
|
|
800
|
+
const extra = written.length ? `\nbundled files: ${written.join(", ")}` : "";
|
|
801
|
+
return { ok: true, result: `saved skill "${name}" to ${filePath}${extra} (listed from next turn)` };
|
|
251
802
|
},
|
|
252
803
|
},
|
|
253
804
|
];
|
|
@@ -265,6 +816,8 @@ export async function executeTool(name, rawArgs, ctx) {
|
|
|
265
816
|
const def = TOOLS.find((t) => t.name === name);
|
|
266
817
|
if (!def)
|
|
267
818
|
return { ok: false, result: `unknown tool: ${name}` };
|
|
819
|
+
if (ctx.signal?.aborted)
|
|
820
|
+
return { ok: false, result: "aborted (harness shutdown)" };
|
|
268
821
|
let args;
|
|
269
822
|
try {
|
|
270
823
|
args = rawArgs ? JSON.parse(rawArgs) : {};
|