teapot-coding-agent 0.7.0 → 0.9.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 -7
- package/dist/agent/agent.js +92 -25
- package/dist/agent/tools.js +538 -20
- package/dist/bus.js +1 -1
- package/dist/server/api.js +31 -3
- package/package.json +3 -1
- package/public/assets/index-BE9Nw_-t.css +1 -0
- package/public/assets/index-DxB6uahH.js +8 -0
- package/public/index.html +2 -2
- package/public/assets/index-C4RhCPYd.js +0 -8
- package/public/assets/index-DIs0rfrk.css +0 -1
package/dist/agent/tools.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
8
|
import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, } from "./skills.js";
|
|
9
9
|
const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
@@ -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,27 +488,110 @@ 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
|
},
|
|
141
505
|
async run(args, ctx) {
|
|
142
506
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
143
|
-
|
|
507
|
+
let text = await readText(p);
|
|
144
508
|
const oldText = str(args.old_text);
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
return { ok: false, result: "old_text
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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;
|
|
513
|
+
let count = text.split(oldText).length - 1;
|
|
514
|
+
let normalized = false;
|
|
515
|
+
// tolerate LF patterns against CRLF files (convert once, on success)
|
|
516
|
+
if (count === 0 && oldText.includes("\n") && text.includes("\r\n")) {
|
|
517
|
+
const lf = text.replace(/\r\n/g, "\n");
|
|
518
|
+
const lfCount = lf.split(oldText).length - 1;
|
|
519
|
+
if (lfCount >= 1) {
|
|
520
|
+
text = lf;
|
|
521
|
+
count = lfCount;
|
|
522
|
+
normalized = true;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
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);
|
|
152
595
|
},
|
|
153
596
|
},
|
|
154
597
|
{
|
|
@@ -169,7 +612,9 @@ export const TOOLS = [
|
|
|
169
612
|
},
|
|
170
613
|
{
|
|
171
614
|
name: "bash",
|
|
172
|
-
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). " +
|
|
173
618
|
"Killed (whole process group) on timeout. stdout+stderr are returned.",
|
|
174
619
|
parameters: {
|
|
175
620
|
type: "object",
|
|
@@ -183,6 +628,77 @@ export const TOOLS = [
|
|
|
183
628
|
return runShell(str(args.command), ctx, Math.min(num(args.timeout_ms, ctx.defaultTimeoutMs), 600_000));
|
|
184
629
|
},
|
|
185
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
|
+
},
|
|
186
702
|
{
|
|
187
703
|
name: "load_skill",
|
|
188
704
|
description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
|
|
@@ -253,6 +769,8 @@ export async function executeTool(name, rawArgs, ctx) {
|
|
|
253
769
|
const def = TOOLS.find((t) => t.name === name);
|
|
254
770
|
if (!def)
|
|
255
771
|
return { ok: false, result: `unknown tool: ${name}` };
|
|
772
|
+
if (ctx.signal?.aborted)
|
|
773
|
+
return { ok: false, result: "aborted (harness shutdown)" };
|
|
256
774
|
let args;
|
|
257
775
|
try {
|
|
258
776
|
args = rawArgs ? JSON.parse(rawArgs) : {};
|
package/dist/bus.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
/** Tiny process-wide pub/sub used to push updates to SSE clients (no polling). */
|
|
3
3
|
export const bus = new EventEmitter();
|
|
4
|
-
bus.setMaxListeners(
|
|
4
|
+
bus.setMaxListeners(1000); // one per connected client (WS + SSE) — headroom for busy LAN setups
|