atom-agent 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/README.md +221 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +502 -21
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +250 -434
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/providers.js +11 -3
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +79 -5
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +1 -1
- package/dist/ui/diff-view.js +13 -5
- package/dist/ui/diff.js +67 -0
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +7 -5
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +81 -22
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +8 -5
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +532 -34
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/package.json +3 -2
package/dist/tools/registry.js
CHANGED
|
@@ -6,7 +6,8 @@ import * as path from "node:path";
|
|
|
6
6
|
import { editTool, readTool, writeTool, } from "./filesystem.js";
|
|
7
7
|
import { GREP_OUTPUT_MODES, globTool, grepTool } from "./search.js";
|
|
8
8
|
import { bashOutputTool, bashTool, } from "./shell.js";
|
|
9
|
-
import {
|
|
9
|
+
import { invalidCall } from "./shared.js";
|
|
10
|
+
import { goalReportOutsideError, validateUpdateGoalArgs } from "../goal.js";
|
|
10
11
|
import { clearCustomTools, customToolNames, getCustomTool, isCustomTool, listCustomTools, registerCustomTool, unregisterCustomTool, validateCustomToolArgs, validateExtensionToolDef, } from "./custom.js";
|
|
11
12
|
export { validateExtensionToolDef } from "./custom.js";
|
|
12
13
|
import { getToolOverride, isToolOverridden, registerToolOverride, unregisterToolOverride, validateExtensionToolOverrideDef, } from "./overrides.js";
|
|
@@ -44,20 +45,38 @@ export function needsApproval(name) {
|
|
|
44
45
|
return custom.requireApproval;
|
|
45
46
|
return false;
|
|
46
47
|
}
|
|
47
|
-
// Known tool names (single source: builtin TOOL_DEFINITIONS plus
|
|
48
|
-
//
|
|
49
|
-
// build "Available: ..." lists
|
|
50
|
-
// from the schema
|
|
48
|
+
// Known tool names (single source: builtin TOOL_DEFINITIONS plus the
|
|
49
|
+
// intercepted update_goal definition below plus extension-registered custom
|
|
50
|
+
// tools, defined below). The validator + loop build "Available: ..." lists
|
|
51
|
+
// from this so the message can never drift from the schema — and the loop's
|
|
52
|
+
// unknown-name gate reads this same list, so model visibility and
|
|
53
|
+
// executability cannot drift apart either.
|
|
51
54
|
export function toolNames() {
|
|
52
|
-
return [
|
|
55
|
+
return [
|
|
56
|
+
...TOOL_DEFINITIONS.map((t) => t.function.name),
|
|
57
|
+
UPDATE_GOAL_TOOL_DEFINITION.function.name,
|
|
58
|
+
...customToolNames(),
|
|
59
|
+
];
|
|
53
60
|
}
|
|
54
|
-
// Every definition the model sees: builtins plus
|
|
55
|
-
//
|
|
56
|
-
//
|
|
61
|
+
// Every definition the model sees: builtins plus the intercepted update_goal
|
|
62
|
+
// definition below plus extension tools. The raw TOOL_DEFINITIONS export
|
|
63
|
+
// stays builtin-only (tests pin its 13 entries); chat payloads must use this
|
|
64
|
+
// so update_goal and custom tools are discoverable. A builtin
|
|
57
65
|
// shadowed by an extension override (ticket 06) keeps its name, schema, and
|
|
58
66
|
// position, but its description carries an audit-visible override marker so
|
|
59
67
|
// the shadowing is never silent.
|
|
60
68
|
export function allToolDefinitions() {
|
|
69
|
+
return chatToolDefinitions(true);
|
|
70
|
+
}
|
|
71
|
+
// Per-POST model-visible surface: identical to allToolDefinitions, minus
|
|
72
|
+
// update_goal when the turn has no live goal to report into. The model
|
|
73
|
+
// repeatedly filed `complete` outside goal turns (greetings, task done-ups)
|
|
74
|
+
// despite the description's WHEN NOT — an invisible tool cannot be misused,
|
|
75
|
+
// and hiding it also trims every no-goal POST by one schema. Executability
|
|
76
|
+
// (toolNames + runInterceptedTool) stays full by design: a hallucinated call
|
|
77
|
+
// still routes to the outside-turn error instead of an unknown-name dead
|
|
78
|
+
// end, and the loop's recordGoalReport backstop is untouched.
|
|
79
|
+
export function chatToolDefinitions(includeUpdateGoal = true) {
|
|
61
80
|
return [
|
|
62
81
|
...TOOL_DEFINITIONS.map((t) => isToolOverridden(t.function.name)
|
|
63
82
|
? {
|
|
@@ -69,6 +88,7 @@ export function allToolDefinitions() {
|
|
|
69
88
|
},
|
|
70
89
|
}
|
|
71
90
|
: t),
|
|
91
|
+
...(includeUpdateGoal ? [UPDATE_GOAL_TOOL_DEFINITION] : []),
|
|
72
92
|
...listCustomTools().map((c) => ({
|
|
73
93
|
type: "function",
|
|
74
94
|
function: { name: c.name, description: c.description, parameters: c.parameters },
|
|
@@ -281,6 +301,10 @@ export function validateToolArgs(name, args) {
|
|
|
281
301
|
}
|
|
282
302
|
case "ask_question":
|
|
283
303
|
return askQuestionDetail(a);
|
|
304
|
+
case "update_goal":
|
|
305
|
+
// Validator lives in goal.ts (same detail-string contract as every
|
|
306
|
+
// other arm here); the schema and executor live beside this arm above.
|
|
307
|
+
return validateUpdateGoalArgs(a);
|
|
284
308
|
default:
|
|
285
309
|
return null;
|
|
286
310
|
}
|
|
@@ -377,39 +401,32 @@ export async function executeTool(name, args, cwd = process.cwd()) {
|
|
|
377
401
|
return e instanceof Error ? `Error: ${e.message}` : `Error: ${String(e)}`;
|
|
378
402
|
}
|
|
379
403
|
}
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return invalid;
|
|
389
|
-
return err("ask_question has no UI hook");
|
|
390
|
-
}
|
|
404
|
+
// Intercepted tools (ask_question/update_goal) resolve without an
|
|
405
|
+
// executor through the registry runner above: validation first (model
|
|
406
|
+
// mistakes never run), then the context-free result — the no-hook error
|
|
407
|
+
// for ask_question, the outside-turn error for update_goal. Direct calls
|
|
408
|
+
// never throw.
|
|
409
|
+
const intercepted = await runInterceptedTool(name, a, {});
|
|
410
|
+
if (intercepted !== null)
|
|
411
|
+
return intercepted.result;
|
|
391
412
|
const detail = validateToolArgs(name, a);
|
|
392
413
|
if (detail)
|
|
393
414
|
return invalidCall(detail);
|
|
394
415
|
return executeBuiltinTool(name, a, cwd);
|
|
395
416
|
}
|
|
396
417
|
// Pristine builtin execution (ticket 06: the override passthrough target).
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
418
|
+
// Intercepted names resolve context-free first, then validation, then the
|
|
419
|
+
// builtin executor — exactly the path above, so pass-through behavior is
|
|
420
|
+
// byte-identical to no override. Never consults the override store, so
|
|
421
|
+
// recursion is impossible by construction. A throwing executor degrades to
|
|
422
|
+
// an `Error:` result string, never a crash.
|
|
401
423
|
async function executeBuiltinTool(name, args, cwd) {
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const invalid = validateAskQuestionArgs(args);
|
|
409
|
-
if (invalid)
|
|
410
|
-
return invalid;
|
|
411
|
-
return err("ask_question has no UI hook");
|
|
412
|
-
}
|
|
424
|
+
// Intercepted tools have no executor: the pristine path resolves them
|
|
425
|
+
// context-free (validated, then the no-hook / outside-turn error), so an
|
|
426
|
+
// override passthrough behaves byte-identically to no override.
|
|
427
|
+
const intercepted = await runInterceptedTool(name, args, {});
|
|
428
|
+
if (intercepted !== null)
|
|
429
|
+
return intercepted.result;
|
|
413
430
|
const detail = validateToolArgs(name, args);
|
|
414
431
|
if (detail)
|
|
415
432
|
return invalidCall(detail);
|
|
@@ -598,9 +615,10 @@ export const TOOL_DEFINITIONS = [
|
|
|
598
615
|
function: {
|
|
599
616
|
name: "read",
|
|
600
617
|
description: "Read a UTF-8 text file with 1-based line numbers (`<n>: <text>` per line) or list a directory (plain entry names, no line numbers). " +
|
|
618
|
+
"PNG, JPEG, GIF, and WebP images (up to 8 MiB) are read as vision input — the result says so and the image reaches the model automatically; describe what you see. " +
|
|
601
619
|
"WHEN to use: inspecting source before editing — read first, then edit with an exact oldString copied from the numbered output; " +
|
|
602
620
|
"paging large files with the offset/limit line window (output truncates with a follow pointer). " +
|
|
603
|
-
"WHEN NOT to use: binaries
|
|
621
|
+
"WHEN NOT to use: other binaries (PDF, audio, video) are rejected — convert to PNG/text first (e.g. pdftoppm, pdftotext); huge dumps — narrow with grep/glob first. " +
|
|
604
622
|
"Paths may be relative or absolute, anywhere on the computer.",
|
|
605
623
|
parameters: {
|
|
606
624
|
type: "object",
|
|
@@ -640,7 +658,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
640
658
|
description: "Edit a file with exact-match string replacement. " +
|
|
641
659
|
"WHEN to use: small targeted changes to an already-read file — read first, then pass the exact oldString copied from the numbered output " +
|
|
642
660
|
"(line numbers are display-only, never file content; never invent oldString from memory). " +
|
|
643
|
-
"WHEN NOT to use: don't create or rewrite whole files (use write). " +
|
|
661
|
+
"WHEN NOT to use: don't create or rewrite whole files (use write). Files over 1MB are refused outright — use bash for targeted changes to huge files. " +
|
|
644
662
|
"oldString must match exactly once unless replaceAll is true. Enforces a stale-read guard: re-read after any external change. " +
|
|
645
663
|
"Edits report the occurrence count. Asks for approval in normal mode.",
|
|
646
664
|
parameters: {
|
|
@@ -660,11 +678,13 @@ export const TOOL_DEFINITIONS = [
|
|
|
660
678
|
type: "function",
|
|
661
679
|
function: {
|
|
662
680
|
name: "grep",
|
|
663
|
-
description: "Search file contents under dir (default '.') for lines matching a JS regex
|
|
664
|
-
"WHEN to use: finding usages
|
|
681
|
+
description: "Search file contents under dir (default '.') for lines matching a JS regex. " +
|
|
682
|
+
"WHEN to use: finding usages — scope with outputMode files_with_matches first, then read. " +
|
|
665
683
|
"WHEN NOT to use: don't list files by name (use glob); don't read whole files (use read). " +
|
|
666
|
-
"
|
|
667
|
-
"
|
|
684
|
+
"Case-sensitive; (?i) prefix = case-insensitive. dir takes a directory or a file. " +
|
|
685
|
+
"include is a glob with {a,b} (e.g. '*.{ts,tsx}'); prefer one scoped call. " +
|
|
686
|
+
"content returns 'file:line: text' (100); files_with_matches lists paths newest-first; " +
|
|
687
|
+
"count adds totals. Binaries skipped; node_modules/.git never searched.",
|
|
668
688
|
parameters: {
|
|
669
689
|
type: "object",
|
|
670
690
|
properties: {
|
|
@@ -686,10 +706,10 @@ export const TOOL_DEFINITIONS = [
|
|
|
686
706
|
type: "function",
|
|
687
707
|
function: {
|
|
688
708
|
name: "glob",
|
|
689
|
-
description: "Find files by glob
|
|
690
|
-
"WHEN to use: locating files by name
|
|
709
|
+
description: "Find files by glob (*, ?, **, {a,b}) under dir (default '.'; a file tests just it). " +
|
|
710
|
+
"WHEN to use: locating files by name — one pattern like '**/*goal*' answers most. " +
|
|
691
711
|
"WHEN NOT to use: don't search contents (use grep); don't read bodies (use read). " +
|
|
692
|
-
"
|
|
712
|
+
"Slash-less patterns match basenames at any depth. Paths newest-first (capped at 200). " +
|
|
693
713
|
"node_modules/.git skipped.",
|
|
694
714
|
parameters: {
|
|
695
715
|
type: "object",
|
|
@@ -921,6 +941,124 @@ export const TOOL_DEFINITIONS = [
|
|
|
921
941
|
},
|
|
922
942
|
},
|
|
923
943
|
];
|
|
944
|
+
// Intercepted tools (ticket 06): loop-resolved, executor-free registry
|
|
945
|
+
// entries — schema, validator, and executor in one place. ask_question's
|
|
946
|
+
// schema stays in TOOL_DEFINITIONS above (its 13-entry pin holds) with its
|
|
947
|
+
// validator (validateToolArgs/validateAskQuestionArgs) and executor
|
|
948
|
+
// (runInterceptedTool) beside it in this module; update_goal's schema lives
|
|
949
|
+
// here (NOT in TOOL_DEFINITIONS, so the 13-entry pin and the
|
|
950
|
+
// scheduler-effects completeness test stay green) with its validator
|
|
951
|
+
// delegating to goal.ts (same detail-string contract — kept home there per
|
|
952
|
+
// the ticket brief) and its executor beside it below. The loop dispatches
|
|
953
|
+
// both through runInterceptedTool via isInterceptedTool — never by name —
|
|
954
|
+
// so the roster below is the single source for model visibility
|
|
955
|
+
// (allToolDefinitions) and executability (toolNames + this runner).
|
|
956
|
+
export const UPDATE_GOAL_TOOL_DEFINITION = {
|
|
957
|
+
type: "function",
|
|
958
|
+
function: {
|
|
959
|
+
name: "update_goal",
|
|
960
|
+
description: "Report this goal turn's outcome (goal-scoped: only available during an active goal turn). " +
|
|
961
|
+
"WHEN to use: at the end of each goal turn — status \"continue\" with the next action, " +
|
|
962
|
+
"or \"complete\"/\"blocked\" with a reason. " +
|
|
963
|
+
"A \"complete\" lands only on genuinely finished work: verified checks and resolved todos. " +
|
|
964
|
+
"Checks you could not run go in \"unverified\" (recorded openly in the closing summary, never a gate). " +
|
|
965
|
+
"WHEN NOT to use: never outside a goal turn (it records nothing there); " +
|
|
966
|
+
"never for greetings, small talk, or non-goal answers; " +
|
|
967
|
+
"a turn with no report continues the goal.",
|
|
968
|
+
parameters: {
|
|
969
|
+
type: "object",
|
|
970
|
+
properties: {
|
|
971
|
+
status: {
|
|
972
|
+
type: "string",
|
|
973
|
+
enum: ["continue", "complete", "blocked"],
|
|
974
|
+
description: "Turn outcome: \"continue\" (keep working), \"complete\" (goal done), \"blocked\" (cannot proceed).",
|
|
975
|
+
},
|
|
976
|
+
next: {
|
|
977
|
+
type: "string",
|
|
978
|
+
description: "Next action (only with status \"continue\"; omit otherwise).",
|
|
979
|
+
},
|
|
980
|
+
reason: {
|
|
981
|
+
type: "string",
|
|
982
|
+
description: "Why the goal is done or stuck (required with \"complete\"/\"blocked\"; omit otherwise).",
|
|
983
|
+
},
|
|
984
|
+
unverified: {
|
|
985
|
+
type: "array",
|
|
986
|
+
items: { type: "string" },
|
|
987
|
+
description: "Checks that could not be run (only with status \"complete\"; omit otherwise). " +
|
|
988
|
+
"Recorded openly in the closing summary; at most 10 non-empty items of 200 characters each.",
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
required: ["status"],
|
|
992
|
+
additionalProperties: false,
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
};
|
|
996
|
+
// Roster query for the loop's dispatch stage: true exactly for the tools
|
|
997
|
+
// runInterceptedTool resolves (never by name in the caller).
|
|
998
|
+
export function isInterceptedTool(name) {
|
|
999
|
+
return name === "ask_question" || name === UPDATE_GOAL_TOOL_DEFINITION.function.name;
|
|
1000
|
+
}
|
|
1001
|
+
// Resolve one intercepted call: validated, approval-free, executor-free.
|
|
1002
|
+
// Returns null for non-intercepted names (the caller falls through to the
|
|
1003
|
+
// executor path). Cancel-like askUser failures rethrow raw — the pipeline
|
|
1004
|
+
// maps them to LoopCancelledError (it cannot be named here: the pipeline
|
|
1005
|
+
// imports this module, so that edge would cycle); everything else is an
|
|
1006
|
+
// `Error:` result string, never a throw.
|
|
1007
|
+
export async function runInterceptedTool(name, parsed, ctx = {}) {
|
|
1008
|
+
switch (name) {
|
|
1009
|
+
case "ask_question":
|
|
1010
|
+
return { result: await runAskQuestionTool(parsed, ctx), decision: "ask-question" };
|
|
1011
|
+
case "update_goal":
|
|
1012
|
+
return { result: runUpdateGoalTool(parsed, ctx), decision: "goal-report" };
|
|
1013
|
+
default:
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
// Local cancel classification (mirrors agent/tool-pipeline's isCancelError,
|
|
1018
|
+
// which cannot be imported here for the cycle reason above).
|
|
1019
|
+
function isInterceptCancel(e, signal) {
|
|
1020
|
+
if (signal?.aborted)
|
|
1021
|
+
return true;
|
|
1022
|
+
if (e instanceof Error && (e.name === "LoopCancelledError" || e.name === "AbortError"))
|
|
1023
|
+
return true;
|
|
1024
|
+
if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
async function runAskQuestionTool(parsed, ctx) {
|
|
1030
|
+
const invalid = validateAskQuestionArgs(parsed);
|
|
1031
|
+
if (invalid)
|
|
1032
|
+
return invalid;
|
|
1033
|
+
if (!ctx.askUser)
|
|
1034
|
+
return "Error: ask_question has no UI hook";
|
|
1035
|
+
const q = parsed;
|
|
1036
|
+
const allowCustom = q.allowCustom === true;
|
|
1037
|
+
try {
|
|
1038
|
+
const answer = await ctx.askUser(q.question, q.options, allowCustom);
|
|
1039
|
+
if (typeof answer === "string" && answer.startsWith("Error:"))
|
|
1040
|
+
return answer;
|
|
1041
|
+
return JSON.stringify({ answer });
|
|
1042
|
+
}
|
|
1043
|
+
catch (e) {
|
|
1044
|
+
// A cancelled turn rethrows raw (the pipeline maps it); an Esc-style
|
|
1045
|
+
// cancel message is the user-cancellable result, never a throw.
|
|
1046
|
+
if (isInterceptCancel(e, ctx.signal))
|
|
1047
|
+
throw e;
|
|
1048
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1049
|
+
if (/cancel/i.test(msg))
|
|
1050
|
+
return "Error: question cancelled by user";
|
|
1051
|
+
return `Error: ${msg}`;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
function runUpdateGoalTool(parsed, ctx) {
|
|
1055
|
+
const detail = validateUpdateGoalArgs(parsed);
|
|
1056
|
+
if (detail)
|
|
1057
|
+
return invalidCall(detail);
|
|
1058
|
+
if (!ctx.onUpdateGoal)
|
|
1059
|
+
return goalReportOutsideError();
|
|
1060
|
+
return ctx.onUpdateGoal(parsed);
|
|
1061
|
+
}
|
|
924
1062
|
// One-line summaries for the /tools command (single source of truth for
|
|
925
1063
|
// the tool list shown in the TUI).
|
|
926
1064
|
export const TOOL_ONE_LINERS = {
|
|
@@ -945,7 +1083,8 @@ export const TOOL_ONE_LINERS = {
|
|
|
945
1083
|
// shadow. Returns an unregister function for hot-reload style removal.
|
|
946
1084
|
export function registerExtensionTool(def) {
|
|
947
1085
|
validateExtensionToolDef(def);
|
|
948
|
-
if (TOOL_DEFINITIONS.some((t) => t.function.name === def.name)
|
|
1086
|
+
if (TOOL_DEFINITIONS.some((t) => t.function.name === def.name) ||
|
|
1087
|
+
def.name === UPDATE_GOAL_TOOL_DEFINITION.function.name) {
|
|
949
1088
|
throw new Error(`extension tool "${def.name}" collides with a builtin tool`);
|
|
950
1089
|
}
|
|
951
1090
|
const unregister = registerCustomTool(def);
|
package/dist/tools/ripgrep.js
CHANGED
|
@@ -115,10 +115,11 @@ function isRegexError(stderr) {
|
|
|
115
115
|
// (caller renders the mode's empty shape); anything else is a fallback
|
|
116
116
|
// signal — with regex-parse errors being the EXPECTED fallback trigger
|
|
117
117
|
// (lookahead and friends are valid JS, invalid Rust).
|
|
118
|
-
async function runRgSearch(absDir, baseArgs, pattern) {
|
|
118
|
+
async function runRgSearch(absDir, baseArgs, pattern, caseInsensitive = false) {
|
|
119
119
|
let res;
|
|
120
120
|
try {
|
|
121
|
-
|
|
121
|
+
const flagArgs = caseInsensitive ? ["-i", ...baseArgs] : baseArgs;
|
|
122
|
+
res = await runRg(absDir, [...flagArgs, "-e", pattern, "--", "."]);
|
|
122
123
|
}
|
|
123
124
|
catch {
|
|
124
125
|
return { kind: "fallback" };
|
|
@@ -173,8 +174,8 @@ function parseJsonEvents(stdout) {
|
|
|
173
174
|
// order. Hits carry raw line text; the caller applies the shared 200-char
|
|
174
175
|
// trim + 100-hit cap + note (same code as the walker path would — see
|
|
175
176
|
// grepTool; this module only supplies ordered raw material).
|
|
176
|
-
export async function rgContentHits(absDir, cwd, pattern, allowed) {
|
|
177
|
-
const run = await runRgSearch(absDir, [...BASE_ARGS, "--json", "--max-count", String(RG_CONTENT_MAX_COUNT)], pattern);
|
|
177
|
+
export async function rgContentHits(absDir, cwd, pattern, allowed, caseInsensitive = false) {
|
|
178
|
+
const run = await runRgSearch(absDir, [...BASE_ARGS, "--json", "--max-count", String(RG_CONTENT_MAX_COUNT)], pattern, caseInsensitive);
|
|
178
179
|
if (run.kind !== "ok")
|
|
179
180
|
return run.kind === "empty" ? { counts: [], hits: [], cappedFile: false } : null;
|
|
180
181
|
const perFile = new Map();
|
|
@@ -226,8 +227,8 @@ export async function rgContentHits(absDir, cwd, pattern, allowed) {
|
|
|
226
227
|
// `rg --count` (ripgrep --count semantics already match the walker's).
|
|
227
228
|
// files_with_matches derives its file set from these counts, so one spawn
|
|
228
229
|
// serves both modes. Caller applies totals/caps/recency.
|
|
229
|
-
export async function rgFileCounts(absDir, cwd, pattern, allowed) {
|
|
230
|
-
const run = await runRgSearch(absDir, [...BASE_ARGS, "--count"], pattern);
|
|
230
|
+
export async function rgFileCounts(absDir, cwd, pattern, allowed, caseInsensitive = false) {
|
|
231
|
+
const run = await runRgSearch(absDir, [...BASE_ARGS, "--count"], pattern, caseInsensitive);
|
|
231
232
|
if (run.kind !== "ok")
|
|
232
233
|
return run.kind === "empty" ? { counts: [] } : null;
|
|
233
234
|
const counts = [];
|
package/dist/tools/search.js
CHANGED
|
@@ -5,9 +5,68 @@ import * as path from "node:path";
|
|
|
5
5
|
import { listFiles } from "./dir-cache.js";
|
|
6
6
|
import { appendOverflow } from "./overflow.js";
|
|
7
7
|
import { noteRgFallback, rgAvailable, rgContentHits, rgFileCounts, rgMinFiles, } from "./ripgrep.js";
|
|
8
|
-
import { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
|
|
9
|
-
// Minimal glob matcher: supports **, **/, *,
|
|
10
|
-
//
|
|
8
|
+
import { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, READ_CHAR_CAP, READ_FILE_MAX_BYTES, resolveSandbox, truncateHead } from "./shared.js";
|
|
9
|
+
// Minimal glob matcher: supports **, **/, *, ?, and {a,b,c} brace
|
|
10
|
+
// alternation (single- or multi-level, e.g. "*.{ts,tsx}" or
|
|
11
|
+
// "src/**/*.{test,spec}.ts"). Patterns without a slash match the basename.
|
|
12
|
+
function expandBraces(pattern) {
|
|
13
|
+
// Cap: a pathological "{a,b}x{a,b}x..." chain explodes combinatorially;
|
|
14
|
+
// past the cap the raw pattern stands (legacy literal-brace behavior).
|
|
15
|
+
const MAX_EXPANSIONS = 128;
|
|
16
|
+
const out = expandBracesInner(pattern);
|
|
17
|
+
return out.length > MAX_EXPANSIONS ? [pattern] : out;
|
|
18
|
+
}
|
|
19
|
+
function expandBracesInner(pattern) {
|
|
20
|
+
const open = pattern.indexOf("{");
|
|
21
|
+
if (open < 0)
|
|
22
|
+
return [pattern];
|
|
23
|
+
// Find the matching close brace, accounting for nesting.
|
|
24
|
+
let depth = 0;
|
|
25
|
+
let close = -1;
|
|
26
|
+
for (let i = open; i < pattern.length; i++) {
|
|
27
|
+
if (pattern[i] === "{")
|
|
28
|
+
depth += 1;
|
|
29
|
+
else if (pattern[i] === "}") {
|
|
30
|
+
depth -= 1;
|
|
31
|
+
if (depth === 0) {
|
|
32
|
+
close = i;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (close < 0)
|
|
38
|
+
return [pattern]; // unbalanced — literal
|
|
39
|
+
const prefix = pattern.slice(0, open);
|
|
40
|
+
const suffix = pattern.slice(close + 1);
|
|
41
|
+
const inner = pattern.slice(open + 1, close);
|
|
42
|
+
// Split on top-level commas only (nested braces stay intact per part).
|
|
43
|
+
const parts = [];
|
|
44
|
+
let partDepth = 0;
|
|
45
|
+
let current = "";
|
|
46
|
+
for (const ch of inner) {
|
|
47
|
+
if (ch === "{")
|
|
48
|
+
partDepth += 1;
|
|
49
|
+
else if (ch === "}")
|
|
50
|
+
partDepth -= 1;
|
|
51
|
+
if (ch === "," && partDepth === 0) {
|
|
52
|
+
parts.push(current);
|
|
53
|
+
current = "";
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
current += ch;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
parts.push(current);
|
|
60
|
+
if (parts.length < 2)
|
|
61
|
+
return [pattern]; // no alternation — literal
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
for (const expanded of expandBracesInner(`${prefix}${part}${suffix}`)) {
|
|
65
|
+
out.push(expanded);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
11
70
|
function globToRegExp(glob) {
|
|
12
71
|
let re = "";
|
|
13
72
|
let i = 0;
|
|
@@ -43,11 +102,35 @@ function globToRegExp(glob) {
|
|
|
43
102
|
}
|
|
44
103
|
function matchesGlob(pattern, relPosix) {
|
|
45
104
|
const norm = pattern.replace(/\\/g, "/");
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
105
|
+
for (const alt of expandBraces(norm)) {
|
|
106
|
+
if (!alt.includes("/")) {
|
|
107
|
+
const base = relPosix.slice(relPosix.lastIndexOf("/") + 1);
|
|
108
|
+
if (globToRegExp(alt).test(base))
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Full-rel match first (exact glob semantics); then a trailing-suffix
|
|
113
|
+
// fallback so a repo-rooted shorthand like "tools/registry.ts" still
|
|
114
|
+
// hits "src/tools/registry.ts" instead of silently matching nothing
|
|
115
|
+
// (the Temp-session c21 trap: correct file, correct pattern shape,
|
|
116
|
+
// zero results, one wasted round plus a manual fallback).
|
|
117
|
+
if (globToRegExp(alt).test(relPosix))
|
|
118
|
+
return true;
|
|
119
|
+
if (globToRegExp(`**/${alt}`).test(relPosix))
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
49
122
|
}
|
|
50
|
-
return
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
// Grep pattern pre-parse: a leading "(?i)" prefix selects case-insensitive
|
|
126
|
+
// matching (Python-trained models reach for it; JS RegExp has no inline
|
|
127
|
+
// flags, so `new RegExp("(?i)goal")` throws "invalid regex"). The prefix is
|
|
128
|
+
// stripped and re-applied as the `i` flag for the walker and as `-i` for
|
|
129
|
+
// ripgrep — one documented spelling, both engines agree.
|
|
130
|
+
function parseGrepPattern(raw) {
|
|
131
|
+
if (raw.startsWith("(?i)"))
|
|
132
|
+
return { source: raw.slice(4), caseInsensitive: true };
|
|
133
|
+
return { source: raw, caseInsensitive: false };
|
|
51
134
|
}
|
|
52
135
|
export const GREP_OUTPUT_MODES = new Set([
|
|
53
136
|
"content",
|
|
@@ -85,9 +168,9 @@ export function formatGrepHit(rel, lineNo, line) {
|
|
|
85
168
|
// shape as the walker scan below (or null = run the walker). Content hits
|
|
86
169
|
// arrive merged in (file alpha, line) order; the 100-hit cap + note apply
|
|
87
170
|
// exactly like the walker path (including its take-100-blindly quirk).
|
|
88
|
-
async function scanWithRipgrep(absDir, cwd, pattern, mode, allowed) {
|
|
171
|
+
async function scanWithRipgrep(absDir, cwd, pattern, mode, allowed, caseInsensitive = false) {
|
|
89
172
|
if (mode === "content") {
|
|
90
|
-
const r = await rgContentHits(absDir, cwd, pattern, allowed);
|
|
173
|
+
const r = await rgContentHits(absDir, cwd, pattern, allowed, caseInsensitive);
|
|
91
174
|
if (r === null)
|
|
92
175
|
return null;
|
|
93
176
|
if (r.cappedFile)
|
|
@@ -95,7 +178,7 @@ async function scanWithRipgrep(absDir, cwd, pattern, mode, allowed) {
|
|
|
95
178
|
const hits = r.hits.slice(0, GREP_MATCH_CAP).map((h) => formatGrepHit(h.rel, h.line, h.text));
|
|
96
179
|
return { counts: r.counts, hits, hitsCapped: r.hits.length >= GREP_MATCH_CAP };
|
|
97
180
|
}
|
|
98
|
-
const r = await rgFileCounts(absDir, cwd, pattern, allowed);
|
|
181
|
+
const r = await rgFileCounts(absDir, cwd, pattern, allowed, caseInsensitive);
|
|
99
182
|
if (r === null)
|
|
100
183
|
return null;
|
|
101
184
|
return { counts: r.counts, hits: [], hitsCapped: false };
|
|
@@ -128,7 +211,20 @@ async function scanWithWalker(cwd, re, pattern, sorted, include, mode) {
|
|
|
128
211
|
if (include && !matchesGlob(include, rel))
|
|
129
212
|
return null;
|
|
130
213
|
try {
|
|
131
|
-
|
|
214
|
+
// OOM guard: the walker reads every file fully and concurrently —
|
|
215
|
+
// one GB input (bundle, pack, media) would OOM the heap. Oversize
|
|
216
|
+
// files skip exactly like binaries (the ripgrep path bounds itself
|
|
217
|
+
// via --max-count instead).
|
|
218
|
+
const full = path.resolve(cwd, rel);
|
|
219
|
+
try {
|
|
220
|
+
const st = await fsp.stat(full);
|
|
221
|
+
if (st.isFile() && st.size > READ_FILE_MAX_BYTES)
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// stat failure falls through to the read below (same as before)
|
|
226
|
+
}
|
|
227
|
+
const text = await fsp.readFile(full, "utf8");
|
|
132
228
|
if (text.includes("\0"))
|
|
133
229
|
return null; // binary — skip
|
|
134
230
|
return { rel, text };
|
|
@@ -172,6 +268,51 @@ async function scanWithWalker(cwd, re, pattern, sorted, include, mode) {
|
|
|
172
268
|
}
|
|
173
269
|
return { counts, hits, hitsCapped };
|
|
174
270
|
}
|
|
271
|
+
// Single-file grep (file-path tolerance for `dir`): same match semantics as
|
|
272
|
+
// the walker over a one-entry set, same output shapes per mode. Skips
|
|
273
|
+
// oversize/binary files exactly like the walker (→ "No matches.").
|
|
274
|
+
async function grepSingleFile(cwd, re, pattern, rel, abs, include, mode) {
|
|
275
|
+
if (include && !matchesGlob(include, rel))
|
|
276
|
+
return "No matches.";
|
|
277
|
+
try {
|
|
278
|
+
const st = await fsp.stat(abs);
|
|
279
|
+
if (st.isFile() && st.size > READ_FILE_MAX_BYTES)
|
|
280
|
+
return "No matches.";
|
|
281
|
+
const text = await fsp.readFile(abs, "utf8");
|
|
282
|
+
if (text.includes("\0"))
|
|
283
|
+
return "No matches.";
|
|
284
|
+
const lines = text.split("\n");
|
|
285
|
+
const hits = [];
|
|
286
|
+
let n = 0;
|
|
287
|
+
for (let i = 0; i < lines.length; i++) {
|
|
288
|
+
let matched;
|
|
289
|
+
try {
|
|
290
|
+
matched = re.test(lines[i]);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return err(`regex failed on input: ${pattern}`);
|
|
294
|
+
}
|
|
295
|
+
re.lastIndex = 0;
|
|
296
|
+
if (!matched)
|
|
297
|
+
continue;
|
|
298
|
+
n += 1;
|
|
299
|
+
if (mode === "content" && hits.length < GREP_MATCH_CAP) {
|
|
300
|
+
hits.push(formatGrepHit(rel, i + 1, lines[i]));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (n === 0)
|
|
304
|
+
return "No matches.";
|
|
305
|
+
if (mode === "files_with_matches")
|
|
306
|
+
return capSearchOutput(`Found 1 file(s)\n${rel}`, "grep results");
|
|
307
|
+
if (mode === "count") {
|
|
308
|
+
return capSearchOutput(`${rel}:${n}\nFound ${n} total match(es) across 1 file(s).`, "grep results");
|
|
309
|
+
}
|
|
310
|
+
return capSearchOutput(hits.join("\n"), "grep results");
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return "No matches.";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
175
316
|
// Line-regex search under dir (default "."). `include` is a glob like
|
|
176
317
|
// "*.ts". `outputMode` selects the shape (Claude-Code-style):
|
|
177
318
|
// - "content" (default): "file:line: text" lines, capped at 100 matches.
|
|
@@ -183,12 +324,13 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
183
324
|
try {
|
|
184
325
|
if (typeof args?.pattern !== "string")
|
|
185
326
|
return err("pattern must be a string");
|
|
327
|
+
const parsed = parseGrepPattern(args.pattern);
|
|
186
328
|
let re;
|
|
187
329
|
try {
|
|
188
|
-
re = new RegExp(
|
|
330
|
+
re = new RegExp(parsed.source, parsed.caseInsensitive ? "i" : "");
|
|
189
331
|
}
|
|
190
332
|
catch {
|
|
191
|
-
return err(`invalid regex: ${args.pattern}`);
|
|
333
|
+
return err(`invalid regex: ${args.pattern} (JS RegExp syntax; prefix with (?i) for case-insensitive)`);
|
|
192
334
|
}
|
|
193
335
|
const mode = args?.outputMode ?? "content";
|
|
194
336
|
if (!GREP_OUTPUT_MODES.has(mode)) {
|
|
@@ -205,10 +347,17 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
205
347
|
catch {
|
|
206
348
|
return err(`no such directory: ${dir}`);
|
|
207
349
|
}
|
|
350
|
+
const include = typeof args.include === "string" && args.include.length > 0 ? args.include : null;
|
|
351
|
+
// File-path tolerance: `dir` pointing at a file searches just that file
|
|
352
|
+
// (models habitually pass "src/foo.ts" as dir). Same output shapes as
|
|
353
|
+
// the directory path; ripgrep is skipped (cwd-anchored by construction).
|
|
354
|
+
if (st.isFile()) {
|
|
355
|
+
const rel = path.relative(cwd, r.abs).split(path.sep).join("/");
|
|
356
|
+
return grepSingleFile(cwd, re, parsed.source, rel, r.abs, include, mode);
|
|
357
|
+
}
|
|
208
358
|
if (!st.isDirectory())
|
|
209
|
-
return err(`not a directory: ${dir}`);
|
|
359
|
+
return err(`not a directory: ${dir} (pass a directory in dir, or read the file directly)`);
|
|
210
360
|
const files = await listFiles(r.abs, cwd);
|
|
211
|
-
const include = typeof args.include === "string" && args.include.length > 0 ? args.include : null;
|
|
212
361
|
const sorted = files.sort();
|
|
213
362
|
// Allowed set shared by both scan paths (include filtering is identical
|
|
214
363
|
// either way, so ripgrep coverage matches the walker exactly).
|
|
@@ -220,7 +369,7 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
220
369
|
let hits;
|
|
221
370
|
let hitsCapped;
|
|
222
371
|
if (rgAvailable() && sorted.length >= rgMinFiles()) {
|
|
223
|
-
const fast = await scanWithRipgrep(r.abs, cwd,
|
|
372
|
+
const fast = await scanWithRipgrep(r.abs, cwd, parsed.source, mode, allowed, parsed.caseInsensitive);
|
|
224
373
|
if (fast !== null) {
|
|
225
374
|
({ counts, hits, hitsCapped } = fast);
|
|
226
375
|
}
|
|
@@ -285,8 +434,14 @@ export async function globTool(args, cwd = process.cwd()) {
|
|
|
285
434
|
catch {
|
|
286
435
|
return err(`no such directory: ${dir}`);
|
|
287
436
|
}
|
|
437
|
+
// File-path tolerance: `dir` pointing at a file tests just that file
|
|
438
|
+
// against the glob (models habitually pass "src/foo.ts" as dir).
|
|
439
|
+
if (st.isFile()) {
|
|
440
|
+
const rel = path.relative(cwd, r.abs).split(path.sep).join("/");
|
|
441
|
+
return matchesGlob(args.pattern, rel) ? capSearchOutput(rel, "glob results") : "No matches.";
|
|
442
|
+
}
|
|
288
443
|
if (!st.isDirectory())
|
|
289
|
-
return err(`not a directory: ${dir}`);
|
|
444
|
+
return err(`not a directory: ${dir} (pass a directory in dir, or read the file directly)`);
|
|
290
445
|
const files = await listFiles(r.abs, cwd);
|
|
291
446
|
const matched = files.filter((rel) => matchesGlob(args.pattern, rel));
|
|
292
447
|
const withTime = await Promise.all(matched.map(async (rel) => ({ rel, t: await mtimeMs(path.resolve(cwd, rel)) })));
|
package/dist/tools/shared.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
export const READ_CHAR_CAP = 64 * 1024;
|
|
5
5
|
export const OUTPUT_CAP = 8 * 1024;
|
|
6
|
+
// Full-read byte guard: no tool may materialize a whole file past this size.
|
|
7
|
+
// A GB file becomes ~2x bytes as UTF-16 plus split/join copies — enough to
|
|
8
|
+
// OOM the heap in a single call (observed kill: long session + one huge
|
|
9
|
+
// read). Matches the diff engine + approval preview 1MB precedent, so the
|
|
10
|
+
// model gets one consistent "over 1MB" story everywhere.
|
|
11
|
+
export const READ_FILE_MAX_BYTES = 1_000_000;
|
|
6
12
|
export const GREP_MATCH_CAP = 100;
|
|
7
13
|
export const GLOB_MATCH_CAP = 200;
|
|
8
14
|
export const SKIP_DIRS = new Set(["node_modules", ".git"]);
|