s-app-scoder-agent 0.8.21 → 0.9.3
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 +31 -5
- package/dist/agent.js +662 -65
- package/dist/agents-guide.js +201 -0
- package/dist/agents.js +76 -3
- package/dist/compaction.js +12 -4
- package/dist/config.js +16 -2
- package/dist/context.js +15 -3
- package/dist/diagnostics.js +188 -0
- package/dist/index.js +277 -6
- package/dist/mcp-import.js +204 -0
- package/dist/mcp.js +553 -54
- package/dist/memory.js +140 -0
- package/dist/permissions.js +18 -3
- package/dist/prompt.js +204 -84
- package/dist/repo-map.js +177 -0
- package/dist/sandbox.js +6 -5
- package/dist/session.js +29 -8
- package/dist/skills.js +211 -62
- package/dist/snapshot.js +5 -0
- package/dist/steering.js +4 -0
- package/dist/tools.js +374 -57
- package/dist/tui/app.js +79 -28
- package/dist/tui/ask-user-modal.js +25 -0
- package/dist/tui/chat-views.js +15 -8
- package/dist/tui/chrome.js +2 -1
- package/dist/tui/command-registry.js +284 -9
- package/dist/tui/commands.js +224 -37
- package/dist/tui/markdown.js +170 -12
- package/dist/tui/sidebar.js +36 -13
- package/dist/tui/state.js +101 -2
- package/dist/tui/theme.js +4 -0
- package/dist/tui/transcript.js +145 -0
- package/dist/watch.js +8 -0
- package/dist/webfetch.js +5 -2
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -3,12 +3,13 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import { resolve, dirname, basename, join, relative, isAbsolute, sep } from "node:path";
|
|
4
4
|
import { unifiedDiff } from "./diff.js";
|
|
5
5
|
import { detectImageBytes } from "./tui/image.js";
|
|
6
|
-
import { discoverSkills, findSkill } from "./skills.js";
|
|
6
|
+
import { discoverSkills, findSkill, loadSkillSection } from "./skills.js";
|
|
7
7
|
import { redactSecrets } from "./llm.js";
|
|
8
8
|
import { loadRcFile } from "./config.js";
|
|
9
9
|
import { resolveSandbox } from "./sandbox.js";
|
|
10
10
|
import * as ui from "./ui.js";
|
|
11
11
|
import { toolWebFetch } from "./webfetch.js";
|
|
12
|
+
import { buildRepoMap, renderRepoMap } from "./repo-map.js";
|
|
12
13
|
/**
|
|
13
14
|
* G7: max chars tool output — default 20_000, runtime override qua
|
|
14
15
|
* setMaxOutputChars (agent constructor gọi với config.limits).
|
|
@@ -56,7 +57,7 @@ function safePath(workdir, p) {
|
|
|
56
57
|
for (;;) {
|
|
57
58
|
try {
|
|
58
59
|
const real = realpathSync(probe);
|
|
59
|
-
const reconstructed = real + missing.map((s) => `/${s}`).join("");
|
|
60
|
+
const reconstructed = real + [...missing].reverse().map((s) => `/${s}`).join("");
|
|
60
61
|
const rel = relative(realWorkdir, reconstructed.length > 0 ? reconstructed : real);
|
|
61
62
|
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
62
63
|
throw new Error(`Path escapes working directory: ${p}`);
|
|
@@ -97,12 +98,22 @@ export function isProtectedEnvFile(relPath) {
|
|
|
97
98
|
return "ask";
|
|
98
99
|
return null;
|
|
99
100
|
}
|
|
101
|
+
/** Coerce a schema-provided numeric-ish arg to a positive int, else fallback. */
|
|
102
|
+
function toPosInt(v, fallback) {
|
|
103
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
104
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
|
|
105
|
+
}
|
|
100
106
|
function toolReadFile(workdir, args) {
|
|
101
107
|
return toResult(() => {
|
|
102
108
|
const abs = safePath(workdir, args.path);
|
|
103
109
|
// H-04: .env read policy — ask trước khi trả nội dung (secrets hygiene).
|
|
104
110
|
const rel = relative(workdir, abs);
|
|
105
|
-
|
|
111
|
+
let policyPath = rel;
|
|
112
|
+
try {
|
|
113
|
+
policyPath = relative(workdir, realpathSync(abs));
|
|
114
|
+
}
|
|
115
|
+
catch { /* handled by safePath */ }
|
|
116
|
+
const envPolicy = isProtectedEnvFile(policyPath.toLowerCase());
|
|
106
117
|
if (envPolicy === "ask") {
|
|
107
118
|
return {
|
|
108
119
|
ok: false,
|
|
@@ -135,10 +146,20 @@ function toolReadFile(workdir, args) {
|
|
|
135
146
|
if (image) {
|
|
136
147
|
return { ok: true, output: `[image: ${args.path}]`, image };
|
|
137
148
|
}
|
|
149
|
+
if (statSync(abs).size > MAX_FILE_SIZE_BYTES) {
|
|
150
|
+
return { ok: false, output: `ERROR: ${args.path} exceeds the ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB edit limit.` };
|
|
151
|
+
}
|
|
138
152
|
const content = readFileSync(abs, "utf8");
|
|
139
153
|
const lines = content.split("\n");
|
|
140
|
-
const
|
|
141
|
-
|
|
154
|
+
const total = lines.length;
|
|
155
|
+
// v2: line-range reads — offset (1-based, default 1) + limit (default all).
|
|
156
|
+
const offset = Math.min(toPosInt(args.offset, 1), total);
|
|
157
|
+
const limit = Math.min(toPosInt(args.limit, total - offset + 1), 2000);
|
|
158
|
+
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
|
159
|
+
const numbered = slice.map((l, i) => `${offset + i}: ${l}`).join("\n");
|
|
160
|
+
const end = offset + slice.length - 1;
|
|
161
|
+
const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
|
|
162
|
+
return { ok: true, output: clamp(header + numbered) };
|
|
142
163
|
});
|
|
143
164
|
}
|
|
144
165
|
function toToolDiff(path, diff) {
|
|
@@ -206,6 +227,47 @@ async function toolWriteFile(ctx, args) {
|
|
|
206
227
|
diff: toToolDiff(args.path, diff),
|
|
207
228
|
};
|
|
208
229
|
}
|
|
230
|
+
/** v2: whitespace-insensitive regex for edit_file fuzzy fallback. */
|
|
231
|
+
function wsInsensitiveRegex(s) {
|
|
232
|
+
let rx = "";
|
|
233
|
+
for (const ch of s) {
|
|
234
|
+
if (/\s/.test(ch))
|
|
235
|
+
rx += "\\s+";
|
|
236
|
+
else
|
|
237
|
+
rx += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
238
|
+
}
|
|
239
|
+
return new RegExp(rx);
|
|
240
|
+
}
|
|
241
|
+
/** Apply one exact (or whitespace-tolerant) replacement to in-memory content. */
|
|
242
|
+
function applyOneEdit(content, path, oldString, newString) {
|
|
243
|
+
if (typeof oldString !== "string" || oldString.length === 0) {
|
|
244
|
+
return { ok: false, error: "old_string must be a non-empty string." };
|
|
245
|
+
}
|
|
246
|
+
if (content.includes(oldString)) {
|
|
247
|
+
const count = content.split(oldString).length - 1;
|
|
248
|
+
if (count > 1) {
|
|
249
|
+
return { ok: false, error: `old_string matches ${count} locations in ${path}; include more surrounding context to make it unique.` };
|
|
250
|
+
}
|
|
251
|
+
// Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
|
|
252
|
+
// which would corrupt literal text like "$&" in the new content.
|
|
253
|
+
return { ok: true, content: content.replace(oldString, () => newString), fuzzy: false };
|
|
254
|
+
}
|
|
255
|
+
// v2 fuzzy fallback: exact string missing → retry with whitespace-tolerant
|
|
256
|
+
// matching (indent/trailing-space drift between model and file). Reports
|
|
257
|
+
// ambiguity instead of guessing when multiple regions match.
|
|
258
|
+
const all = [...content.matchAll(new RegExp(wsInsensitiveRegex(oldString).source, "g"))];
|
|
259
|
+
if (all.length === 0) {
|
|
260
|
+
return { ok: false, error: `old_string not found in ${path}. Read the file again and retry with exact text.` };
|
|
261
|
+
}
|
|
262
|
+
if (all.length > 1) {
|
|
263
|
+
return { ok: false, error: `old_string not found exactly and ${all.length} whitespace-tolerant matches exist in ${path}; include more surrounding context to disambiguate.` };
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
ok: true,
|
|
267
|
+
content: content.slice(0, all[0].index) + newString + content.slice(all[0].index + all[0][0].length),
|
|
268
|
+
fuzzy: true,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
209
271
|
async function toolEditFile(ctx, args) {
|
|
210
272
|
let abs;
|
|
211
273
|
try {
|
|
@@ -215,16 +277,10 @@ async function toolEditFile(ctx, args) {
|
|
|
215
277
|
return { ok: false, output: `ERROR: ${err.message}` };
|
|
216
278
|
}
|
|
217
279
|
const content = readFileSync(abs, "utf8");
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
if (count > 1) {
|
|
223
|
-
return { ok: false, output: `old_string matches ${count} locations in ${args.path}; include more surrounding context to make it unique.` };
|
|
224
|
-
}
|
|
225
|
-
// Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
|
|
226
|
-
// which would corrupt literal text like "$&" in the new content.
|
|
227
|
-
const newContent = content.replace(args.old_string, () => args.new_string);
|
|
280
|
+
const outcome = applyOneEdit(content, args.path, args.old_string, args.new_string);
|
|
281
|
+
if (!outcome.ok)
|
|
282
|
+
return { ok: false, output: outcome.error };
|
|
283
|
+
const newContent = outcome.content;
|
|
228
284
|
const diff = unifiedDiff(args.path, content, newContent);
|
|
229
285
|
const gate = await gateWrite(ctx, args.path, diff);
|
|
230
286
|
if (gate)
|
|
@@ -233,7 +289,65 @@ async function toolEditFile(ctx, args) {
|
|
|
233
289
|
const fmtNote = formatFile(ctx, abs);
|
|
234
290
|
return {
|
|
235
291
|
ok: true,
|
|
236
|
-
output: `Edited ${args.path}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
292
|
+
output: `Edited ${args.path}${outcome.fuzzy ? " (fuzzy whitespace match)" : ""}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
293
|
+
diff: toToolDiff(args.path, diff),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
async function toolApplyPatch(ctx, args) {
|
|
297
|
+
let abs;
|
|
298
|
+
try {
|
|
299
|
+
abs = safePath(ctx.workdir, args.path);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
303
|
+
}
|
|
304
|
+
if (!Array.isArray(args.edits) || args.edits.length === 0) {
|
|
305
|
+
return { ok: false, output: "ERROR: edits must be a non-empty array." };
|
|
306
|
+
}
|
|
307
|
+
if (args.edits.length > 50) {
|
|
308
|
+
return { ok: false, output: `ERROR: too many edits (${args.edits.length}); split into chunks of at most 50.` };
|
|
309
|
+
}
|
|
310
|
+
let content;
|
|
311
|
+
try {
|
|
312
|
+
if (statSync(abs).size > MAX_FILE_SIZE_BYTES) {
|
|
313
|
+
return { ok: false, output: `ERROR: ${args.path} exceeds the ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB patch limit.` };
|
|
314
|
+
}
|
|
315
|
+
content = readFileSync(abs, "utf8");
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return { ok: false, output: `ERROR: cannot read ${args.path}: ${err.message}` };
|
|
319
|
+
}
|
|
320
|
+
const original = content;
|
|
321
|
+
let fuzzy = 0;
|
|
322
|
+
for (let i = 0; i < args.edits.length; i++) {
|
|
323
|
+
const e = args.edits[i];
|
|
324
|
+
if (!e || typeof e.old_string !== "string" || typeof e.new_string !== "string") {
|
|
325
|
+
return { ok: false, output: `ERROR: edit #${i + 1} must have string old_string and new_string. No changes written.` };
|
|
326
|
+
}
|
|
327
|
+
const outcome = applyOneEdit(content, args.path, e.old_string, e.new_string);
|
|
328
|
+
if (!outcome.ok) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
output: `ERROR: edit #${i + 1}/${args.edits.length} failed — ${outcome.error} No changes written.`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
content = outcome.content;
|
|
335
|
+
if (outcome.fuzzy)
|
|
336
|
+
fuzzy++;
|
|
337
|
+
}
|
|
338
|
+
if (content === original) {
|
|
339
|
+
return { ok: true, output: `No changes to ${args.path} (all edits were no-ops).` };
|
|
340
|
+
}
|
|
341
|
+
const diff = unifiedDiff(args.path, original, content);
|
|
342
|
+
const gate = await gateWrite(ctx, args.path, diff);
|
|
343
|
+
if (gate)
|
|
344
|
+
return gate;
|
|
345
|
+
writeFileSync(abs, content, "utf8");
|
|
346
|
+
const fmtNote = formatFile(ctx, abs);
|
|
347
|
+
const fuzzyNote = fuzzy > 0 ? ` (${fuzzy} fuzzy whitespace match${fuzzy === 1 ? "" : "es"})` : "";
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
output: `Applied ${args.edits.length} edit${args.edits.length === 1 ? "" : "s"} to ${args.path}${fuzzyNote}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
237
351
|
diff: toToolDiff(args.path, diff),
|
|
238
352
|
};
|
|
239
353
|
}
|
|
@@ -241,9 +355,17 @@ function toolListFiles(workdir, args) {
|
|
|
241
355
|
return toResult(() => {
|
|
242
356
|
const root = safePath(workdir, args.path ?? ".");
|
|
243
357
|
const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
358
|
+
// v2: configurable depth (default 4, cap 8), hidden-dotfiles opt-in, exclude globs.
|
|
359
|
+
const maxDepth = Math.min(toPosInt(args.depth, 4), 8);
|
|
360
|
+
const includeHidden = args.include_hidden === true || args.include_hidden === "true";
|
|
361
|
+
const excludeRx = args.exclude
|
|
362
|
+
? new RegExp(args.exclude.split(/[,\s]+/).filter(Boolean)
|
|
363
|
+
.map((g) => globToRegExp(g).source)
|
|
364
|
+
.join("|"))
|
|
365
|
+
: null;
|
|
244
366
|
const out = [];
|
|
245
367
|
const walk = (dir, depth) => {
|
|
246
|
-
if (depth >
|
|
368
|
+
if (depth > maxDepth || out.length > 500)
|
|
247
369
|
return;
|
|
248
370
|
let entries;
|
|
249
371
|
try {
|
|
@@ -255,15 +377,20 @@ function toolListFiles(workdir, args) {
|
|
|
255
377
|
for (const e of entries) {
|
|
256
378
|
if (skip.has(e))
|
|
257
379
|
continue;
|
|
380
|
+
if (!includeHidden && e.startsWith(".") && e !== ".github")
|
|
381
|
+
continue;
|
|
382
|
+
if (excludeRx?.test(e))
|
|
383
|
+
continue;
|
|
258
384
|
const full = join(dir, e);
|
|
259
385
|
let st;
|
|
260
386
|
try {
|
|
387
|
+
safePath(workdir, relative(workdir, full));
|
|
261
388
|
st = statSync(full);
|
|
262
389
|
}
|
|
263
390
|
catch {
|
|
264
391
|
continue;
|
|
265
392
|
}
|
|
266
|
-
const rel = relative(workdir, full);
|
|
393
|
+
const rel = relative(workdir, full).split(sep).join("/");
|
|
267
394
|
if (st.isDirectory()) {
|
|
268
395
|
out.push(`${rel}/`);
|
|
269
396
|
walk(full, depth + 1);
|
|
@@ -314,6 +441,10 @@ export function toolGrep(workdir, args) {
|
|
|
314
441
|
if (!pattern)
|
|
315
442
|
return { ok: false, output: "ERROR: pattern is required." };
|
|
316
443
|
const root = safePath(workdir, args.path ?? ".");
|
|
444
|
+
// v2: context lines + hit cap — before/after look like ripgrep -B/-A.
|
|
445
|
+
const ctxBefore = Math.min(toPosInt(args.context_before, 0), 20);
|
|
446
|
+
const ctxAfter = Math.min(toPosInt(args.context_after, 0), 20);
|
|
447
|
+
const maxResults = Math.min(toPosInt(args.max_results, MAX_SEARCH_HITS), 100);
|
|
317
448
|
// Fast path: ripgrep (respects .gitignore, fast, JSON output). Skip
|
|
318
449
|
// common noise dirs even when the project has no .gitignore.
|
|
319
450
|
const rgArgs = [
|
|
@@ -324,22 +455,42 @@ export function toolGrep(workdir, args) {
|
|
|
324
455
|
"--glob", "!build", "--glob", "!.next", "--glob", "!__pycache__",
|
|
325
456
|
"--glob", "!.venv",
|
|
326
457
|
];
|
|
458
|
+
if (ctxBefore > 0)
|
|
459
|
+
rgArgs.push("-B", String(ctxBefore));
|
|
460
|
+
if (ctxAfter > 0)
|
|
461
|
+
rgArgs.push("-A", String(ctxAfter));
|
|
327
462
|
if (args.include)
|
|
328
463
|
rgArgs.push("-g", args.include);
|
|
464
|
+
if (args.type)
|
|
465
|
+
rgArgs.push("--type", String(args.type));
|
|
329
466
|
const rg = spawnSync("rg", rgArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
330
467
|
const lines = [];
|
|
331
468
|
let count = 0;
|
|
332
469
|
if ((rg.status ?? 2) === 0 || rg.stdout) {
|
|
470
|
+
let prevLine = -1;
|
|
471
|
+
let prevRel = "";
|
|
333
472
|
for (const line of rg.stdout.split("\n")) {
|
|
334
|
-
if (!line
|
|
473
|
+
if (!line)
|
|
474
|
+
continue;
|
|
475
|
+
if (count >= maxResults)
|
|
335
476
|
break;
|
|
336
477
|
try {
|
|
337
478
|
const j = JSON.parse(line);
|
|
338
|
-
if (j.type !== "match"
|
|
479
|
+
if (!j.data || (j.type !== "match" && j.type !== "context"))
|
|
339
480
|
continue;
|
|
340
481
|
const rel = relative(workdir, j.data.path?.text ?? "").split(sep).join("/");
|
|
341
|
-
|
|
342
|
-
|
|
482
|
+
const ln = j.data.line_number ?? 0;
|
|
483
|
+
const text = (j.data.lines?.text ?? "").trimEnd().slice(0, 200);
|
|
484
|
+
// GNU-style: match lines keep ':' separators, context lines use '-'.
|
|
485
|
+
if (rel !== prevRel || (prevLine >= 0 && ln > prevLine + 1)) {
|
|
486
|
+
if (lines.length > 0)
|
|
487
|
+
lines.push("--");
|
|
488
|
+
}
|
|
489
|
+
lines.push(`${rel}${j.type === "match" ? ":" : "-"}${ln}${j.type === "match" ? ":" : "-"} ${text}`);
|
|
490
|
+
if (j.type === "match")
|
|
491
|
+
count++;
|
|
492
|
+
prevLine = ln;
|
|
493
|
+
prevRel = rel;
|
|
343
494
|
}
|
|
344
495
|
catch { /* skip malformed */ }
|
|
345
496
|
}
|
|
@@ -349,14 +500,30 @@ export function toolGrep(workdir, args) {
|
|
|
349
500
|
// status >= 2 → rg error; fall through to grep fallback.
|
|
350
501
|
}
|
|
351
502
|
// Fallback: grep -rn (portable) — output already path:line:text.
|
|
352
|
-
const
|
|
503
|
+
const gArgs = ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern];
|
|
504
|
+
if (ctxBefore > 0)
|
|
505
|
+
gArgs.push("-B", String(ctxBefore));
|
|
506
|
+
if (ctxAfter > 0)
|
|
507
|
+
gArgs.push("-A", String(ctxAfter));
|
|
508
|
+
gArgs.push(root);
|
|
509
|
+
const g = spawnSync("grep", gArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
353
510
|
if ((g.status ?? 2) <= 1) {
|
|
354
|
-
const hits = (g.stdout ?? "").split("\n").filter(Boolean)
|
|
355
|
-
|
|
356
|
-
|
|
511
|
+
const hits = (g.stdout ?? "").split("\n").filter(Boolean);
|
|
512
|
+
const out = [];
|
|
513
|
+
let matches = 0;
|
|
514
|
+
for (const l of hits) {
|
|
515
|
+
// Context lines GNU-separated with '-'; matches with ':'.
|
|
516
|
+
const isMatch = /^[^:]*:\d+:/.test(l);
|
|
517
|
+
if (isMatch && matches >= maxResults)
|
|
518
|
+
continue;
|
|
519
|
+
if (isMatch)
|
|
520
|
+
matches++;
|
|
521
|
+
out.push(l.startsWith(root) ? relative(workdir, root).split(sep).join("/") + l.slice(root.length) : l);
|
|
522
|
+
}
|
|
523
|
+
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(no matches)" };
|
|
357
524
|
}
|
|
358
525
|
// rg vắng mặt + grep vắng mặt/lỗi → scan JS thuần (luôn chạy được, không cần binary).
|
|
359
|
-
return jsGrepFallback(workdir, root, pattern, args.include);
|
|
526
|
+
return jsGrepFallback(workdir, root, pattern, args.include, ctxBefore, ctxAfter, maxResults);
|
|
360
527
|
});
|
|
361
528
|
}
|
|
362
529
|
/**
|
|
@@ -365,7 +532,7 @@ export function toolGrep(workdir, args) {
|
|
|
365
532
|
* — quan trọng trên Windows máy chưa cài ripgrep. Pattern là regex JS; regex
|
|
366
533
|
* không hợp lệ → coi như literal substring.
|
|
367
534
|
*/
|
|
368
|
-
function jsGrepFallback(workdir, root, pattern, include) {
|
|
535
|
+
function jsGrepFallback(workdir, root, pattern, include, ctxBefore = 0, ctxAfter = 0, maxResults = MAX_SEARCH_HITS) {
|
|
369
536
|
let rx;
|
|
370
537
|
try {
|
|
371
538
|
rx = new RegExp(pattern);
|
|
@@ -377,8 +544,9 @@ function jsGrepFallback(workdir, root, pattern, include) {
|
|
|
377
544
|
? globToRegExp(include.includes("/") ? include : `**/${include}`)
|
|
378
545
|
: null;
|
|
379
546
|
const hits = [];
|
|
547
|
+
let matches = 0;
|
|
380
548
|
walkFiles(root, (_name, abs) => {
|
|
381
|
-
if (
|
|
549
|
+
if (matches >= maxResults)
|
|
382
550
|
return;
|
|
383
551
|
const rel = relative(workdir, abs).split(sep).join("/");
|
|
384
552
|
if (includeRx && !includeRx.test(rel))
|
|
@@ -391,11 +559,34 @@ function jsGrepFallback(workdir, root, pattern, include) {
|
|
|
391
559
|
if (buf.includes(0))
|
|
392
560
|
return; // skip binary (NUL byte)
|
|
393
561
|
const lines = buf.toString("utf8").split("\n");
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
562
|
+
// Collect matching line numbers, then emit merged windows with context.
|
|
563
|
+
const matchIdx = [];
|
|
564
|
+
for (let i = 0; i < lines.length && matchIdx.length < 5; i++) {
|
|
565
|
+
if (rx.test(lines[i]))
|
|
566
|
+
matchIdx.push(i);
|
|
567
|
+
}
|
|
568
|
+
if (matchIdx.length === 0)
|
|
569
|
+
return;
|
|
570
|
+
// Merge overlapping windows.
|
|
571
|
+
const windows = [];
|
|
572
|
+
for (const i of matchIdx) {
|
|
573
|
+
const lo = Math.max(0, i - ctxBefore), hi = Math.min(lines.length - 1, i + ctxAfter);
|
|
574
|
+
const last = windows[windows.length - 1];
|
|
575
|
+
if (last && lo <= last[1] + 1)
|
|
576
|
+
last[1] = Math.max(last[1], hi);
|
|
577
|
+
else
|
|
578
|
+
windows.push([lo, hi]);
|
|
579
|
+
}
|
|
580
|
+
for (const [lo, hi] of windows) {
|
|
581
|
+
if (hits.length > 0 && hits[hits.length - 1] !== "--")
|
|
582
|
+
hits.push("--");
|
|
583
|
+
for (let i = lo; i <= hi; i++) {
|
|
584
|
+
const isMatch = matchIdx.includes(i);
|
|
585
|
+
if (isMatch && matches >= maxResults)
|
|
586
|
+
break;
|
|
587
|
+
hits.push(`${rel}${isMatch ? ":" : "-"}${i + 1}${isMatch ? ":" : "-"} ${lines[i].trimEnd().slice(0, 200)}`);
|
|
588
|
+
if (isMatch)
|
|
589
|
+
matches++;
|
|
399
590
|
}
|
|
400
591
|
}
|
|
401
592
|
}
|
|
@@ -645,12 +836,14 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox) {
|
|
|
645
836
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
646
837
|
child.stdout?.on("data", (c) => {
|
|
647
838
|
const s = c.toString("utf8");
|
|
648
|
-
stdout
|
|
839
|
+
if (stdout.length < MAX_OUTPUT_CHARS)
|
|
840
|
+
stdout += s.slice(0, MAX_OUTPUT_CHARS - stdout.length);
|
|
649
841
|
onOutput?.("stdout", redactSecrets(s)); // G3
|
|
650
842
|
});
|
|
651
843
|
child.stderr?.on("data", (c) => {
|
|
652
844
|
const s = c.toString("utf8");
|
|
653
|
-
stderr
|
|
845
|
+
if (stderr.length < MAX_OUTPUT_CHARS)
|
|
846
|
+
stderr += s.slice(0, MAX_OUTPUT_CHARS - stderr.length);
|
|
654
847
|
onOutput?.("stderr", redactSecrets(s)); // G3
|
|
655
848
|
});
|
|
656
849
|
child.on("error", (err) => {
|
|
@@ -694,6 +887,7 @@ const obj = (properties, required) => ({
|
|
|
694
887
|
additionalProperties: false,
|
|
695
888
|
});
|
|
696
889
|
const str = (description) => ({ type: "string", description });
|
|
890
|
+
const num = (description) => ({ type: "number", description });
|
|
697
891
|
export const AGENT_TOOLS = [
|
|
698
892
|
{
|
|
699
893
|
spec: {
|
|
@@ -701,7 +895,10 @@ export const AGENT_TOOLS = [
|
|
|
701
895
|
function: {
|
|
702
896
|
name: "load_skill",
|
|
703
897
|
description: "Load the full instructions of a named skill from the AVAILABLE SKILLS index (project + global). Call this at the start of a task when it matches a skill, then follow the returned instructions exactly.",
|
|
704
|
-
parameters: obj({
|
|
898
|
+
parameters: obj({
|
|
899
|
+
name: str("Skill name from the AVAILABLE SKILLS index, e.g. 'prompt-gate-workflow'"),
|
|
900
|
+
section: str("Optional section heading to load (progressive disclosure). Omit for full body."),
|
|
901
|
+
}, ["name"]),
|
|
705
902
|
},
|
|
706
903
|
},
|
|
707
904
|
run: (ctx, args) => {
|
|
@@ -713,7 +910,10 @@ export const AGENT_TOOLS = [
|
|
|
713
910
|
const names = discoverSkills(ctx.workdir).map((s) => s.name).join(", ");
|
|
714
911
|
return { ok: false, output: `ERROR: skill "${name}" not found${names ? ` — available: ${names}` : " — no skills discovered"}.` };
|
|
715
912
|
}
|
|
716
|
-
|
|
913
|
+
const section = args.section;
|
|
914
|
+
const body = section ? loadSkillSection(skill, String(section)) : skill.body;
|
|
915
|
+
const refs = skill.references?.length ? `\n\nReferences: ${skill.references.join(", ")}` : "";
|
|
916
|
+
return { ok: true, output: `# skill: ${skill.name} (${skill.scope})${skill.version ? ` v${skill.version}` : ""}\n\n${body}${refs}` };
|
|
717
917
|
},
|
|
718
918
|
},
|
|
719
919
|
{
|
|
@@ -721,7 +921,8 @@ export const AGENT_TOOLS = [
|
|
|
721
921
|
type: "function",
|
|
722
922
|
function: {
|
|
723
923
|
name: "write_todos",
|
|
724
|
-
description: "Write the full task plan (replaces the previous list). Use at the start of a multi-step task and update as you go: mark items completed IMMEDIATELY after finishing them, set exactly one item in_progress, and add newly discovered steps.
|
|
924
|
+
description: "Write the full task plan (replaces the previous list). Use at the start of a multi-step task and update as you go: mark items completed IMMEDIATELY after finishing them, set exactly one item in_progress, and add newly discovered steps. " +
|
|
925
|
+
"v2: each item may carry verify (how you will know it's done), depends_on (earlier step ids), files (expected paths) and acceptance (criteria). The user sees this list live.",
|
|
725
926
|
parameters: obj({
|
|
726
927
|
todos: {
|
|
727
928
|
type: "array",
|
|
@@ -729,8 +930,13 @@ export const AGENT_TOOLS = [
|
|
|
729
930
|
items: {
|
|
730
931
|
type: "object",
|
|
731
932
|
properties: {
|
|
933
|
+
id: { type: "string", description: "Stable step id (e.g. 't1'). Auto-assigned when omitted." },
|
|
732
934
|
content: { type: "string", description: "Imperative step, e.g. 'Fix null check in UserService'" },
|
|
733
935
|
status: { type: "string", enum: ["pending", "in_progress", "completed"] },
|
|
936
|
+
verify: { type: "string", description: "How to verify the step — command or observable behavior" },
|
|
937
|
+
depends_on: { type: "array", items: { type: "string" }, description: "Ids of steps that must complete first" },
|
|
938
|
+
files: { type: "array", items: { type: "string" }, description: "Files this step is expected to touch" },
|
|
939
|
+
acceptance: { type: "string", description: "Criteria for the step to count as done" },
|
|
734
940
|
},
|
|
735
941
|
required: ["content", "status"],
|
|
736
942
|
additionalProperties: false,
|
|
@@ -744,18 +950,45 @@ export const AGENT_TOOLS = [
|
|
|
744
950
|
if (!Array.isArray(todos))
|
|
745
951
|
return { ok: false, output: "ERROR: todos must be an array of {content, status}." };
|
|
746
952
|
const clean = [];
|
|
747
|
-
for (const t of todos) {
|
|
953
|
+
for (const [i, t] of todos.entries()) {
|
|
748
954
|
if (typeof t?.content !== "string" || !t.content.trim()) {
|
|
749
955
|
return { ok: false, output: "ERROR: every todo needs a non-empty content string." };
|
|
750
956
|
}
|
|
751
957
|
if (!["pending", "in_progress", "completed"].includes(t.status)) {
|
|
752
958
|
return { ok: false, output: `ERROR: invalid status "${String(t.status)}" (pending|in_progress|completed).` };
|
|
753
959
|
}
|
|
754
|
-
|
|
960
|
+
const item = {
|
|
961
|
+
id: typeof t.id === "string" && t.id.trim() ? t.id.trim().slice(0, 32) : `t${i + 1}`,
|
|
962
|
+
content: t.content.trim().slice(0, 200),
|
|
963
|
+
status: t.status,
|
|
964
|
+
};
|
|
965
|
+
if (typeof t.verify === "string" && t.verify.trim())
|
|
966
|
+
item.verify = t.verify.trim().slice(0, 300);
|
|
967
|
+
if (typeof t.acceptance === "string" && t.acceptance.trim())
|
|
968
|
+
item.acceptance = t.acceptance.trim().slice(0, 300);
|
|
969
|
+
if (Array.isArray(t.depends_on))
|
|
970
|
+
item.depends_on = t.depends_on.filter((x) => typeof x === "string").slice(0, 10);
|
|
971
|
+
if (Array.isArray(t.files))
|
|
972
|
+
item.files = t.files.filter((x) => typeof x === "string").slice(0, 20);
|
|
973
|
+
clean.push(item);
|
|
974
|
+
}
|
|
975
|
+
// v2 rule: exactly one in_progress at a time.
|
|
976
|
+
const inProgress = clean.filter((t) => t.status === "in_progress");
|
|
977
|
+
if (inProgress.length > 1) {
|
|
978
|
+
return { ok: false, output: `ERROR: exactly one in_progress step allowed (got ${inProgress.length}: ${inProgress.map((t) => t.content).join(" | ")}). Mark the others pending or completed.` };
|
|
979
|
+
}
|
|
980
|
+
// v2 rule: depends_on must reference earlier ids in the SAME plan.
|
|
981
|
+
const ids = new Set(clean.map((t) => t.id));
|
|
982
|
+
for (const t of clean) {
|
|
983
|
+
for (const dep of t.depends_on ?? []) {
|
|
984
|
+
if (!ids.has(dep))
|
|
985
|
+
return { ok: false, output: `ERROR: step "${t.content}" depends_on unknown id "${dep}" — ids in this plan: ${[...ids].join(", ")}` };
|
|
986
|
+
}
|
|
755
987
|
}
|
|
756
988
|
ctx.plan?.set(clean);
|
|
757
989
|
const done = clean.filter((t) => t.status === "completed").length;
|
|
758
|
-
|
|
990
|
+
const next = clean.find((t) => t.status === "in_progress");
|
|
991
|
+
return { ok: true, output: `Plan updated: ${done}/${clean.length} completed.${next ? ` In progress: ${next.content}` : " (none in progress)"}` };
|
|
759
992
|
},
|
|
760
993
|
},
|
|
761
994
|
{
|
|
@@ -763,8 +996,13 @@ export const AGENT_TOOLS = [
|
|
|
763
996
|
type: "function",
|
|
764
997
|
function: {
|
|
765
998
|
name: "list_files",
|
|
766
|
-
description: "List files and directories recursively under a path in the working directory.",
|
|
767
|
-
parameters: obj({
|
|
999
|
+
description: "List files and directories recursively under a path in the working directory. Hidden dotfiles are excluded unless include_hidden=true; pass depth / exclude to shape the output.",
|
|
1000
|
+
parameters: obj({
|
|
1001
|
+
path: str("Relative path, defaults to '.'"),
|
|
1002
|
+
depth: num("Max directory depth to recurse (default 4, cap 8)"),
|
|
1003
|
+
exclude: str("Comma/space-separated globs to exclude, e.g. '*.log,coverage'"),
|
|
1004
|
+
include_hidden: str("'true' to include dotfiles (default: excluded)"),
|
|
1005
|
+
}, []),
|
|
768
1006
|
},
|
|
769
1007
|
},
|
|
770
1008
|
run: (ctx, args) => toolListFiles(ctx.workdir, args),
|
|
@@ -774,8 +1012,12 @@ export const AGENT_TOOLS = [
|
|
|
774
1012
|
type: "function",
|
|
775
1013
|
function: {
|
|
776
1014
|
name: "read_file",
|
|
777
|
-
description: "Read a text file. Returns content with 1-based line numbers. Image files (PNG/JPG/GIF) return an `[image: path]` placeholder rendered inline by the TUI.",
|
|
778
|
-
parameters: obj({
|
|
1015
|
+
description: "Read a text file. Returns content with 1-based line numbers. Pass offset (1-based start line) and limit to read a slice instead of the whole file. Image files (PNG/JPG/GIF) return an `[image: path]` placeholder rendered inline by the TUI.",
|
|
1016
|
+
parameters: obj({
|
|
1017
|
+
path: str("Relative file path"),
|
|
1018
|
+
offset: num("1-based line to start from (default: 1)"),
|
|
1019
|
+
limit: num("Max lines to read (default: whole file, capped at 2000)"),
|
|
1020
|
+
}, ["path"]),
|
|
779
1021
|
},
|
|
780
1022
|
},
|
|
781
1023
|
run: (ctx, args) => toolReadFile(ctx.workdir, args),
|
|
@@ -796,7 +1038,8 @@ export const AGENT_TOOLS = [
|
|
|
796
1038
|
type: "function",
|
|
797
1039
|
function: {
|
|
798
1040
|
name: "edit_file",
|
|
799
|
-
description: "Replace an exact unique substring in a file. old_string must appear exactly once; include enough surrounding context."
|
|
1041
|
+
description: "Replace an exact unique substring in a file. old_string must appear exactly once; include enough surrounding context. " +
|
|
1042
|
+
"For multiple replacements in the same file, use apply_patch (atomic, one write).",
|
|
800
1043
|
parameters: obj({
|
|
801
1044
|
path: str("Relative file path"),
|
|
802
1045
|
old_string: str("Exact text to replace"),
|
|
@@ -806,6 +1049,29 @@ export const AGENT_TOOLS = [
|
|
|
806
1049
|
},
|
|
807
1050
|
run: (ctx, args) => toolEditFile(ctx, args),
|
|
808
1051
|
},
|
|
1052
|
+
{
|
|
1053
|
+
spec: {
|
|
1054
|
+
type: "function",
|
|
1055
|
+
function: {
|
|
1056
|
+
name: "apply_patch",
|
|
1057
|
+
description: "Apply multiple exact replacements to ONE file atomically. Edits run in order; each old_string must match exactly once " +
|
|
1058
|
+
"in the content produced by the previous edits. If any edit fails, nothing is written. " +
|
|
1059
|
+
"Use this instead of repeated edit_file calls for multi-hunk changes.",
|
|
1060
|
+
parameters: obj({
|
|
1061
|
+
path: str("Relative file path"),
|
|
1062
|
+
edits: {
|
|
1063
|
+
type: "array",
|
|
1064
|
+
description: "Ordered list of replacements applied atomically (1–50).",
|
|
1065
|
+
items: obj({
|
|
1066
|
+
old_string: str("Exact text to replace (must be unique at its turn)"),
|
|
1067
|
+
new_string: str("Replacement text"),
|
|
1068
|
+
}, ["old_string", "new_string"]),
|
|
1069
|
+
},
|
|
1070
|
+
}, ["path", "edits"]),
|
|
1071
|
+
},
|
|
1072
|
+
},
|
|
1073
|
+
run: (ctx, args) => toolApplyPatch(ctx, args),
|
|
1074
|
+
},
|
|
809
1075
|
{
|
|
810
1076
|
spec: {
|
|
811
1077
|
type: "function",
|
|
@@ -814,7 +1080,7 @@ export const AGENT_TOOLS = [
|
|
|
814
1080
|
description: "Run a shell command in the working directory (sh/bash -c; Windows: Git Bash when installed, else cmd /c). Use for builds, tests, git, installing deps, etc.",
|
|
815
1081
|
parameters: obj({
|
|
816
1082
|
command: str("Shell command to run"),
|
|
817
|
-
timeout_secs:
|
|
1083
|
+
timeout_secs: num("Timeout in seconds (default 120, max 600)"),
|
|
818
1084
|
}, ["command"]),
|
|
819
1085
|
},
|
|
820
1086
|
},
|
|
@@ -846,12 +1112,16 @@ export const AGENT_TOOLS = [
|
|
|
846
1112
|
type: "function",
|
|
847
1113
|
function: {
|
|
848
1114
|
name: "grep",
|
|
849
|
-
description: "Search file contents with a regex (ripgrep-backed, gitignore-aware). Returns path:line:text hits
|
|
850
|
-
"Use for finding code by symbol, error string, or pattern. Combine with read_file for context.",
|
|
1115
|
+
description: "Search file contents with a regex (ripgrep-backed, gitignore-aware). Returns path:line:text hits (max 50 by default), with optional context lines and a hit cap. " +
|
|
1116
|
+
"Use for finding code by symbol, error string, or pattern. Combine with read_file for full context.",
|
|
851
1117
|
parameters: obj({
|
|
852
1118
|
pattern: str("Regular expression to search for"),
|
|
853
1119
|
path: str("Directory or file to search (default: workdir root)"),
|
|
854
1120
|
include: str("Glob filter for filenames, e.g. '*.ts'"),
|
|
1121
|
+
context_before: num("Context lines to print before each match (default 0)"),
|
|
1122
|
+
context_after: num("Context lines to print after each match (default 0)"),
|
|
1123
|
+
max_results: num("Max matching lines to return (default 50, cap 100)"),
|
|
1124
|
+
type: str("ripgrep file type filter, e.g. 'ts', 'py' (rg only; ignored by fallbacks)"),
|
|
855
1125
|
}, ["pattern"]),
|
|
856
1126
|
},
|
|
857
1127
|
},
|
|
@@ -872,6 +1142,29 @@ export const AGENT_TOOLS = [
|
|
|
872
1142
|
},
|
|
873
1143
|
run: (ctx, args) => toolGlob(ctx.workdir, args),
|
|
874
1144
|
},
|
|
1145
|
+
{
|
|
1146
|
+
spec: {
|
|
1147
|
+
type: "function",
|
|
1148
|
+
function: {
|
|
1149
|
+
name: "repo_map",
|
|
1150
|
+
description: "Return the repository map — file tree with sizes + exported symbols (regex-extracted, budget-bounded, cached). " +
|
|
1151
|
+
"Use to orient before reading files; cheaper than listing/reading everything.",
|
|
1152
|
+
parameters: obj({ path: str("Sub-directory to map (default: repo root)") }, []),
|
|
1153
|
+
},
|
|
1154
|
+
},
|
|
1155
|
+
run: (ctx, args) => {
|
|
1156
|
+
try {
|
|
1157
|
+
const sub = typeof args.path === "string" ? args.path.trim() : "";
|
|
1158
|
+
const out = sub
|
|
1159
|
+
? renderRepoMap(ctx.workdir, 4000, sub)
|
|
1160
|
+
: buildRepoMap(ctx.workdir);
|
|
1161
|
+
return { ok: true, output: out };
|
|
1162
|
+
}
|
|
1163
|
+
catch (err) {
|
|
1164
|
+
return { ok: false, output: `ERROR: repo map failed: ${err.message}` };
|
|
1165
|
+
}
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
875
1168
|
];
|
|
876
1169
|
/** task tool — runner resolved from ctx.taskRunner (per-agent, no global). */
|
|
877
1170
|
const taskTool = {
|
|
@@ -879,22 +1172,46 @@ const taskTool = {
|
|
|
879
1172
|
type: "function",
|
|
880
1173
|
function: {
|
|
881
1174
|
name: "task",
|
|
882
|
-
description: "Delegate
|
|
1175
|
+
description: "Delegate self-contained subtasks to subagents that run their own agent loop with their own context. " +
|
|
883
1176
|
"WHEN TO USE: broad codebase exploration (find where X lives, summarize module Y) so the main " +
|
|
884
|
-
"conversation stays lean, or
|
|
885
|
-
"
|
|
1177
|
+
"conversation stays lean, or independent subtasks that need many steps. " +
|
|
1178
|
+
"SINGLE: pass description + prompt. BATCH: pass tasks[] to run up to 8 subtasks concurrently " +
|
|
1179
|
+
"(bounded at 4 in flight) and get one merged result. " +
|
|
1180
|
+
"Every prompt must be self-contained (the subagent sees nothing else); each returns a summary. " +
|
|
886
1181
|
"Prefer this over reading dozens of files yourself.",
|
|
887
1182
|
parameters: obj({
|
|
888
|
-
description: str("One-line summary of the subtask (shown in the UI)"),
|
|
889
|
-
prompt: str("Self-contained instructions for the subagent
|
|
1183
|
+
description: str("One-line summary of the subtask (single mode; shown in the UI)"),
|
|
1184
|
+
prompt: str("Self-contained instructions for the subagent (single mode)"),
|
|
890
1185
|
agent: str("Optional agent name from the available agents (default: explore for research, build for changes)"),
|
|
891
|
-
|
|
1186
|
+
tasks: {
|
|
1187
|
+
type: "array",
|
|
1188
|
+
description: "Batch mode: up to 8 subtasks run concurrently. When set, description/prompt are ignored.",
|
|
1189
|
+
items: obj({
|
|
1190
|
+
description: str("One-line summary of this subtask (shown in the UI)"),
|
|
1191
|
+
prompt: str("Self-contained instructions for this subagent"),
|
|
1192
|
+
agent: str("Optional agent name for this subtask"),
|
|
1193
|
+
}, ["description", "prompt"]),
|
|
1194
|
+
},
|
|
1195
|
+
}, []),
|
|
892
1196
|
},
|
|
893
1197
|
},
|
|
894
1198
|
run: (ctx, args) => {
|
|
895
1199
|
if (!ctx.taskRunner)
|
|
896
1200
|
return { ok: false, output: "ERROR: task tool not wired (no runner in context)." };
|
|
897
|
-
|
|
1201
|
+
const a = args;
|
|
1202
|
+
if (Array.isArray(a.tasks) && a.tasks.length > 0) {
|
|
1203
|
+
if (a.tasks.length > 8) {
|
|
1204
|
+
return { ok: false, output: `ERROR: too many tasks (${a.tasks.length}); split into batches of at most 8.` };
|
|
1205
|
+
}
|
|
1206
|
+
const invalid = a.tasks.findIndex((t) => !t || typeof t.prompt !== "string" || !t.prompt.trim());
|
|
1207
|
+
if (invalid >= 0)
|
|
1208
|
+
return { ok: false, output: `ERROR: tasks[${invalid}] is missing a non-empty prompt.` };
|
|
1209
|
+
return ctx.taskRunner(ctx, { tasks: a.tasks });
|
|
1210
|
+
}
|
|
1211
|
+
if (typeof a.prompt !== "string" || !a.prompt.trim()) {
|
|
1212
|
+
return { ok: false, output: "ERROR: task requires a non-empty `prompt` (single mode) or a non-empty `tasks` array (batch mode)." };
|
|
1213
|
+
}
|
|
1214
|
+
return ctx.taskRunner(ctx, a);
|
|
898
1215
|
},
|
|
899
1216
|
};
|
|
900
1217
|
export function getTool(name) {
|