@warlock.js/ai-workspace 5.43.0 → 5.44.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/CHANGELOG.md +18 -0
- package/README.md +14 -1
- package/cjs/index.cjs +1781 -141
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +36 -22
- package/esm/backends/local.mjs.map +1 -1
- package/esm/contracts/index.d.mts +5 -1
- package/esm/contracts/shell-approval.type.d.mts +78 -0
- package/esm/contracts/shell-approval.type.d.mts.map +1 -0
- package/esm/contracts/tool-io.type.d.mts +35 -1
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +11 -1
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-edit.type.d.mts +66 -0
- package/esm/contracts/workspace-edit.type.d.mts.map +1 -0
- package/esm/contracts/workspace-ops.contract.d.mts +7 -1
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-output.type.d.mts +57 -0
- package/esm/contracts/workspace-output.type.d.mts.map +1 -0
- package/esm/contracts/workspace-policy.type.d.mts +38 -1
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +11 -0
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/edit/apply-replacement.mjs +58 -0
- package/esm/edit/apply-replacement.mjs.map +1 -0
- package/esm/edit/match.d.mts +16 -0
- package/esm/edit/match.d.mts.map +1 -0
- package/esm/edit/match.mjs +214 -0
- package/esm/edit/match.mjs.map +1 -0
- package/esm/errors.d.mts +12 -1
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +1 -0
- package/esm/errors.mjs.map +1 -1
- package/esm/hooks/after-edit.mjs +36 -0
- package/esm/hooks/after-edit.mjs.map +1 -0
- package/esm/index.d.mts +7 -2
- package/esm/index.mjs +3 -2
- package/esm/ops.d.mts +3 -1
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +196 -75
- package/esm/ops.mjs.map +1 -1
- package/esm/output/spill-dir.mjs +15 -0
- package/esm/output/spill-dir.mjs.map +1 -0
- package/esm/output/spill.mjs +220 -0
- package/esm/output/spill.mjs.map +1 -0
- package/esm/policy/policy.d.mts +3 -1
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +12 -4
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/powershell-aliases.mjs +275 -0
- package/esm/policy/powershell-aliases.mjs.map +1 -0
- package/esm/policy/powershell-parser.mjs +418 -0
- package/esm/policy/powershell-parser.mjs.map +1 -0
- package/esm/policy/shell-check.d.mts +32 -2
- package/esm/policy/shell-check.d.mts.map +1 -1
- package/esm/policy/shell-check.mjs +183 -31
- package/esm/policy/shell-check.mjs.map +1 -1
- package/esm/policy/shell-interpreter.d.mts +45 -0
- package/esm/policy/shell-interpreter.d.mts.map +1 -0
- package/esm/policy/shell-interpreter.mjs +109 -0
- package/esm/policy/shell-interpreter.mjs.map +1 -0
- package/esm/policy/shell-parser.d.mts +31 -0
- package/esm/policy/shell-parser.d.mts.map +1 -0
- package/esm/policy/shell-parser.mjs +3 -0
- package/esm/policy/shell-parser.mjs.map +1 -1
- package/esm/tools/glob.mjs +1 -0
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +10 -0
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +18 -1
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +7 -0
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +5 -4
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/write-file.mjs +3 -2
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +16 -5
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +64 -17
- package/package.json +3 -3
- package/skills/use-a-workspace/SKILL.md +64 -17
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-output.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-output.type.ts"],"mappings":";;YACY;;;;;;;;;;;;;;iBAeK;;EAEf;;EAEA;;;;;EAKA;;;;;;EAMA;;;;;EAKA,OAAO;;;;;;iBAOQ;;EAEf;IAAS;;;EAET;IAAS;IAAe;;;EAExB"}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { WorkspaceOutputPolicy } from "./workspace-output.type.mjs";
|
|
2
|
+
import { WorkspaceCommandRule, WorkspaceShellOnAsk } from "./shell-approval.type.mjs";
|
|
3
|
+
import { WorkspaceEditPolicy, WorkspaceHooks } from "./workspace-edit.type.mjs";
|
|
1
4
|
//#region ../../packages/ai-workspace/src/contracts/workspace-policy.type.d.ts
|
|
2
5
|
/**
|
|
3
6
|
* The executor a workspace runs on. `"local"` uses `@warlock.js/fs` +
|
|
@@ -15,6 +18,13 @@ export type WorkspaceBackendType = "local" | "mock";
|
|
|
15
18
|
* (quotes respected, no shell semantics) and spawned without a shell. Any
|
|
16
19
|
* unquoted shell operator is refused with a message that says how to split
|
|
17
20
|
* the line.
|
|
21
|
+
* - `"bash"` / `"powershell"` / `"cmd"` — shell mode through that specific
|
|
22
|
+
* shell (`bash -c`; `powershell -NoLogo -NoProfile -NonInteractive -Command`,
|
|
23
|
+
* preferring `pwsh`; `cmd.exe /d /v:off /s /c`). Each is parsed with its own
|
|
24
|
+
* conservative grammar and every command in it goes through the same checks.
|
|
25
|
+
* - `"auto"` — shell mode through the best shell found on the host: `pwsh`,
|
|
26
|
+
* then Windows PowerShell, then `cmd.exe` on Windows; `bash`, then `sh`
|
|
27
|
+
* elsewhere. `run_shell`'s description names the shell it resolved to.
|
|
18
28
|
* - `"sh"` — **shell mode** (explicit opt-in): the line runs through
|
|
19
29
|
* `/bin/sh -c` (`cmd.exe /d /v:off /s /c` on Windows). Before it runs, the
|
|
20
30
|
* line is parsed and every simple command in it — inside `;`, `&&`, `||`,
|
|
@@ -23,7 +33,7 @@ export type WorkspaceBackendType = "local" | "mock";
|
|
|
23
33
|
* redirection target and `cd` directory against the path jail. Syntax the
|
|
24
34
|
* parser cannot read with confidence is refused, never guessed at.
|
|
25
35
|
*/
|
|
26
|
-
export type WorkspaceShellInterpreter = "none" | "sh";
|
|
36
|
+
export type WorkspaceShellInterpreter = "none" | "sh" | "bash" | "powershell" | "cmd" | "auto";
|
|
27
37
|
/**
|
|
28
38
|
* A command pattern in `shell.denyPatterns`.
|
|
29
39
|
*
|
|
@@ -125,6 +135,27 @@ export interface WorkspaceShellPolicy {
|
|
|
125
135
|
* {@link WorkspaceShellInterpreter}).
|
|
126
136
|
*/
|
|
127
137
|
interpreter?: WorkspaceShellInterpreter;
|
|
138
|
+
/**
|
|
139
|
+
* Absolute path of the shell executable for a shell-mode interpreter,
|
|
140
|
+
* instead of the one found on the host's `PATH` (for example a `pwsh`
|
|
141
|
+
* installed outside `PATH`). Ignored for `"none"`.
|
|
142
|
+
*/
|
|
143
|
+
interpreterPath?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Ordered allow / ask / deny rules (see {@link WorkspaceCommandRule}). For
|
|
146
|
+
* one command the LAST matching rule wins; a `deny` anywhere in a line
|
|
147
|
+
* refuses the whole line. They add to `allow`, `deny` and `denyPatterns`
|
|
148
|
+
* and never relax them. A command no rule matches asks when `onAsk` is
|
|
149
|
+
* set, and otherwise behaves as before.
|
|
150
|
+
*/
|
|
151
|
+
rules?: WorkspaceCommandRule[];
|
|
152
|
+
/**
|
|
153
|
+
* Called once per line with every command that needs approval: `ask`
|
|
154
|
+
* rules, unmatched commands, syntax the policy cannot read and runners it
|
|
155
|
+
* cannot see into. Absent ⇒ a command matching an `ask` rule is refused
|
|
156
|
+
* with reason `"approval-required"`.
|
|
157
|
+
*/
|
|
158
|
+
onAsk?: WorkspaceShellOnAsk;
|
|
128
159
|
/** Per-command wall-clock cap; on expiry the process is SIGKILLed. */
|
|
129
160
|
timeoutMs?: number;
|
|
130
161
|
/**
|
|
@@ -211,6 +242,12 @@ export interface WorkspacePolicy {
|
|
|
211
242
|
read?: WorkspaceReadPolicy;
|
|
212
243
|
/** Directory-listing sub-policy (ignore globs + bounds). */
|
|
213
244
|
list?: WorkspaceListPolicy;
|
|
245
|
+
/** Edit sub-policy (matching tiers, miss hints, line endings). */
|
|
246
|
+
edit?: WorkspaceEditPolicy;
|
|
247
|
+
/** Host callbacks, such as `afterEdit` diagnostics. */
|
|
248
|
+
hooks?: WorkspaceHooks;
|
|
249
|
+
/** Output sub-policy (spill oversized output to a file). */
|
|
250
|
+
output?: WorkspaceOutputPolicy;
|
|
214
251
|
/** Executor the workspace runs on. Defaults to `"local"`. */
|
|
215
252
|
backend?: WorkspaceBackendType;
|
|
216
253
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace-policy.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-policy.type.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"workspace-policy.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-policy.type.ts"],"mappings":";;;;;;;;;;;;YAYY;;;;;;;;;;;;;;;;;;;;;;;YAwBA;;;;;;;;;;;;;YAcA,mCAAmC;;iBAG9B;;EAEf;;EAEA,aAAa;;;;;;EAMb;;;;;;;;;;;;;YAcU,yBACV,yBACA,SAAS;;;;;;;;iBAUM;;EAEf;;;;;;EAMA;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2Be;;EAEf;;EAEA;;;;;EAKA,eAAe;;;;;EAKf,eAAe;;;;;;;EAOf,cAAc;;;;;;EAMd;;;;;;;;EAQA,QAAQ;;;;;;;EAOR,QAAQ;;EAER;;;;;;EAMA;;EAEA,MAAM;;;;;EAKN;;;;;EAKA,aAAa;;;;;;iBAOE;;;;;;;;EAQf;;EAEA;;;;;;;iBAQe;;;;;;EAMf;;EAEA;;EAEA;;;;;;;;;;;;;;;;;;;;;iBAsBe;;EAEf;;EAEA;;EAEA;;EAEA,QAAQ;;EAER,OAAO;;EAEP,OAAO;;EAEP,OAAO;;EAEP,QAAQ;;EAER,SAAS;;EAET,UAAU"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApplyPatchInput, ApplyPatchResult, EditFileInput, EditFileResult, ExecOptions, GrepResult, ListDirOptions, ListDirResult, MultiEditInput, MultiEditResult, ReadFileResult, RunShellResult, ShellJobInfo, ShellOutputOptions, ShellOutputResult, StartShellOptions, TodoListResult, WorkspaceTodoInput, WorkspaceToolName } from "./tool-io.type.mjs";
|
|
2
|
+
import { WorkspaceShellClassification } from "./shell-approval.type.mjs";
|
|
2
3
|
import { WorkspacePolicy } from "./workspace-policy.type.mjs";
|
|
3
4
|
import { ToolContract } from "@warlock.js/ai";
|
|
4
5
|
//#region ../../packages/ai-workspace/src/contracts/workspace.contract.d.ts
|
|
@@ -106,6 +107,16 @@ export interface Workspace {
|
|
|
106
107
|
readonly policy: WorkspacePolicy;
|
|
107
108
|
/** Agent-facing tool factories. */
|
|
108
109
|
readonly tools: WorkspaceTools;
|
|
110
|
+
/** Shell-policy helpers. */
|
|
111
|
+
readonly shell: {
|
|
112
|
+
/**
|
|
113
|
+
* The simple commands a line would run (pipelines, `&&` / `||` / `;`,
|
|
114
|
+
* subshells and substitutions included), read with the workspace's shell
|
|
115
|
+
* dialect and without running or checking anything. A line the parser
|
|
116
|
+
* cannot read with confidence returns `opaque` (why) and no commands.
|
|
117
|
+
*/
|
|
118
|
+
classify(line: string): WorkspaceShellClassification;
|
|
119
|
+
};
|
|
109
120
|
/**
|
|
110
121
|
* Read a jailed file. `offset`/`limit` select a 1-based line window and
|
|
111
122
|
* `read.maxBytes` bounds the returned bytes; returns the numbered content,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace.contract.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"workspace.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace.contract.ts"],"mappings":";;;;;;;;;;;;;;;iBAmCiB;;;;;;EAMf,OAAO;;EAEP,QAAQ,OAAO,sBAAsB;;EAErC,SAAS;IAAS;MAAkB;;EAEpC,SAAS;IAAS;MAAkB;;EAEpC,UAAU;IAAS;MAAkB;;EAErC,WAAW;IAAS;MAAkB;;EAEtC,UAAU;IAAS;MAAkB;;EAErC,QAAQ;IAAS;MAAkB;;EAEnC,SAAS;IAAS;MAAkB;;EAEpC,YAAY;IAAS;MAAkB;;EAEvC,UAAU;IAAS;MAAkB;;EAErC,SAAS;IAAS;IAAe;MAAqB;;EAEtD,KAAK;IAAS;MAAkB;;EAEhC,KAAK;IAAS;MAAkB;;EAEhC,UAAU;IAAS;MAAkB;;EAErC,SAAS;IAAS;MAAkB;;;;;;;;;;;;;;;;;;;;;;;;iBAyBrB;;WAEN,QAAQ;;WAGR,OAAO;;WAGP;;;;;;;IAOP,SAAS,eAAe;;;;;;;;EAS1B,SAAS,cAAc;IAAS;IAAiB;MAAmB,QAAQ;;EAG5E,UAAU,cAAc,kBAAkB;IAAU;IAAc;;;EAGlE,SAAS,OAAO,gBAAgB,QAAQ;;EAGxC,UAAU,OAAO,iBAAiB,QAAQ;;EAG1C,WAAW,OAAO,kBAAkB,QAAQ;;EAG5C,QAAQ,eAAe,OAAO,iBAAiB,QAAQ;;;;;;EAOvD,KAAK,iBAAiB,OAAO,cAAc,QAAQ;;;;;;EAOnD,WAAW,iBAAiB,OAAO,oBAAoB,QAAQ;;EAG/D,YAAY,YAAY,OAAO,qBAAqB,QAAQ;;EAG5D,UAAU,aAAa,QAAQ;;EAG/B,cAAc;;EAGd,aAAa;;EAGb,WAAW,OAAO,uBAAuB;;;;;;;EAQzC,WAAW;;EAGX,KAAK,iBAAiB;IAAS;IAAe;MAAyB,QAAQ;;EAG/E,KAAK,kBAAkB;;EAGvB,OAAO,eAAe;;EAGtB,MAAM,eAAe;;EAGrB,OAAO,eAAe;;;;;;EAOtB,YAAY;;;;;;EAOZ,MAAM,iBAAiB"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { WorkspaceEditError } from "../errors.mjs";
|
|
2
|
+
import { describeMiss, detectEol, locateEdit, withEol } from "./match.mjs";
|
|
3
|
+
|
|
4
|
+
//#region ../../packages/ai-workspace/src/edit/apply-replacement.ts
|
|
5
|
+
/**
|
|
6
|
+
* Apply one replacement to `content` under the edit rules: `oldString` must
|
|
7
|
+
* be found (see {@link WorkspaceEditMatching}), and exactly once unless
|
|
8
|
+
* `replaceAll` — a fuzzy match must always be unique, so a loose match never
|
|
9
|
+
* rewrites several places at once.
|
|
10
|
+
*/
|
|
11
|
+
function applyReplacement(content, edit, inputPath, shownPath, options, editIndex) {
|
|
12
|
+
const { matching } = options;
|
|
13
|
+
const located = edit.oldString === "" ? null : locateEdit(content, edit.oldString, matching);
|
|
14
|
+
const count = located?.ranges.length ?? 0;
|
|
15
|
+
const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
|
|
16
|
+
const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
|
|
17
|
+
const indexOption = editIndex === void 0 ? {} : { editIndex };
|
|
18
|
+
if (located === null || count === 0) {
|
|
19
|
+
let hint = describeMiss(content, edit.oldString, edit.newString, options.missHintLines);
|
|
20
|
+
if (hint !== void 0 && options.hintMaxBytes !== void 0) hint = Buffer.byteLength(hint, "utf8") > options.hintMaxBytes ? void 0 : hint;
|
|
21
|
+
throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".` + (hint === void 0 ? "" : ` ${hint}`) + nothingApplied, {
|
|
22
|
+
type: "not-found",
|
|
23
|
+
path: shownPath,
|
|
24
|
+
matches: 0,
|
|
25
|
+
...hint !== void 0 ? { hint } : {},
|
|
26
|
+
...indexOption
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const fuzzy = located.tier !== "exact";
|
|
30
|
+
if (count > 1 && (!edit.replaceAll || fuzzy)) {
|
|
31
|
+
const advice = fuzzy ? "include more surrounding context." : "pass replaceAll or include more surrounding context.";
|
|
32
|
+
throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${count} matches${fuzzy && located.viaView ? ", found by loose matching" : ""}); ${advice}${nothingApplied}`, {
|
|
33
|
+
type: "not-unique",
|
|
34
|
+
path: shownPath,
|
|
35
|
+
matches: count,
|
|
36
|
+
...indexOption
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const eol = options.lineEndings === "lf" ? "\n" : options.lineEndings === "crlf" ? "\r\n" : matching === "tiered" ? detectEol(content) : void 0;
|
|
40
|
+
const replacement = eol === void 0 ? edit.newString : withEol(edit.newString, eol);
|
|
41
|
+
const ranges = edit.replaceAll ? located.ranges : located.ranges.slice(0, 1);
|
|
42
|
+
let out = "";
|
|
43
|
+
let cursor = 0;
|
|
44
|
+
for (const range of ranges) {
|
|
45
|
+
out += content.slice(cursor, range.start) + replacement;
|
|
46
|
+
cursor = range.end;
|
|
47
|
+
}
|
|
48
|
+
out += content.slice(cursor);
|
|
49
|
+
return {
|
|
50
|
+
content: out,
|
|
51
|
+
replacements: ranges.length,
|
|
52
|
+
...fuzzy ? { matchTier: located.tier } : {}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
export { applyReplacement };
|
|
58
|
+
//# sourceMappingURL=apply-replacement.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply-replacement.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/edit/apply-replacement.ts"],"sourcesContent":["import { WorkspaceEditError } from \"../errors\";\nimport type { MultiEditOperation, WorkspaceEditLineEndings } from \"../contracts\";\nimport {\n describeMiss,\n detectEol,\n locateEdit,\n withEol,\n type WorkspaceEditMatching,\n type WorkspaceEditMatchTier,\n} from \"./match\";\n\n/** What one applied replacement did. */\nexport interface AppliedReplacement {\n content: string;\n replacements: number;\n /** The tier that matched, set only when it was a fuzzy one. */\n matchTier?: Exclude<WorkspaceEditMatchTier, \"exact\">;\n}\n\n/** The edit-policy settings {@link applyReplacement} honours. */\nexport interface AppliedReplacementOptions {\n matching: WorkspaceEditMatching;\n /** Most lines a miss hint shows; `0` for none. */\n missHintLines: number;\n /** Cap on a miss hint's size in bytes (the read policy's `maxBytes`). */\n hintMaxBytes?: number;\n lineEndings: WorkspaceEditLineEndings;\n}\n\n/**\n * Apply one replacement to `content` under the edit rules: `oldString` must\n * be found (see {@link WorkspaceEditMatching}), and exactly once unless\n * `replaceAll` — a fuzzy match must always be unique, so a loose match never\n * rewrites several places at once.\n */\nexport function applyReplacement(\n content: string,\n edit: MultiEditOperation,\n inputPath: string,\n shownPath: string,\n options: AppliedReplacementOptions,\n editIndex?: number,\n): AppliedReplacement {\n const { matching } = options;\n const located = edit.oldString === \"\" ? null : locateEdit(content, edit.oldString, matching);\n const count = located?.ranges.length ?? 0;\n const which = editIndex === undefined ? \"\" : `Edit ${editIndex + 1}: `;\n const nothingApplied = editIndex === undefined ? \"\" : \" None of the edits were applied.\";\n const indexOption = editIndex === undefined ? {} : { editIndex };\n\n if (located === null || count === 0) {\n let hint = describeMiss(content, edit.oldString, edit.newString, options.missHintLines);\n\n if (hint !== undefined && options.hintMaxBytes !== undefined) {\n hint = Buffer.byteLength(hint, \"utf8\") > options.hintMaxBytes ? undefined : hint;\n }\n\n throw new WorkspaceEditError(\n `${which}The text to replace was not found in \"${inputPath}\".` +\n (hint === undefined ? \"\" : ` ${hint}`) +\n nothingApplied,\n {\n type: \"not-found\",\n path: shownPath,\n matches: 0,\n ...(hint !== undefined ? { hint } : {}),\n ...indexOption,\n },\n );\n }\n\n const fuzzy = located.tier !== \"exact\";\n\n if (count > 1 && (!edit.replaceAll || fuzzy)) {\n const advice = fuzzy\n ? \"include more surrounding context.\"\n : \"pass replaceAll or include more surrounding context.\";\n\n throw new WorkspaceEditError(\n `${which}The text to replace is not unique in \"${inputPath}\" (${count} matches${\n fuzzy && located.viaView ? \", found by loose matching\" : \"\"\n }); ${advice}${nothingApplied}`,\n { type: \"not-unique\", path: shownPath, matches: count, ...indexOption },\n );\n }\n\n const eol =\n options.lineEndings === \"lf\"\n ? \"\\n\"\n : options.lineEndings === \"crlf\"\n ? \"\\r\\n\"\n : matching === \"tiered\"\n ? detectEol(content)\n : undefined;\n const replacement = eol === undefined ? edit.newString : withEol(edit.newString, eol);\n const ranges = edit.replaceAll ? located.ranges : located.ranges.slice(0, 1);\n let out = \"\";\n let cursor = 0;\n\n for (const range of ranges) {\n out += content.slice(cursor, range.start) + replacement;\n cursor = range.end;\n }\n\n out += content.slice(cursor);\n\n return {\n content: out,\n replacements: ranges.length,\n ...(fuzzy ? { matchTier: located.tier as \"unicode\" | \"whitespace\" } : {}),\n };\n}\n"],"mappings":";;;;;;;;;;AAmCA,SAAgB,iBACd,SACA,MACA,WACA,WACA,SACA,WACoB;CACpB,MAAM,EAAE,aAAa;CACrB,MAAM,UAAU,KAAK,cAAc,KAAK,OAAO,WAAW,SAAS,KAAK,WAAW,QAAQ;CAC3F,MAAM,QAAQ,SAAS,OAAO,UAAU;CACxC,MAAM,QAAQ,cAAc,SAAY,KAAK,QAAQ,YAAY,EAAE;CACnE,MAAM,iBAAiB,cAAc,SAAY,KAAK;CACtD,MAAM,cAAc,cAAc,SAAY,CAAC,IAAI,EAAE,UAAU;CAE/D,IAAI,YAAY,QAAQ,UAAU,GAAG;EACnC,IAAI,OAAO,aAAa,SAAS,KAAK,WAAW,KAAK,WAAW,QAAQ,aAAa;EAEtF,IAAI,SAAS,UAAa,QAAQ,iBAAiB,QACjD,OAAO,OAAO,WAAW,MAAM,MAAM,IAAI,QAAQ,eAAe,SAAY;EAG9E,MAAM,IAAI,mBACR,GAAG,MAAM,wCAAwC,UAAU,OACxD,SAAS,SAAY,KAAK,IAAI,UAC/B,gBACF;GACE,MAAM;GACN,MAAM;GACN,SAAS;GACT,GAAI,SAAS,SAAY,EAAE,KAAK,IAAI,CAAC;GACrC,GAAG;EACL,CACF;CACF;CAEA,MAAM,QAAQ,QAAQ,SAAS;CAE/B,IAAI,QAAQ,MAAM,CAAC,KAAK,cAAc,QAAQ;EAC5C,MAAM,SAAS,QACX,sCACA;EAEJ,MAAM,IAAI,mBACR,GAAG,MAAM,wCAAwC,UAAU,KAAK,MAAM,UACpE,SAAS,QAAQ,UAAU,8BAA8B,GAC1D,KAAK,SAAS,kBACf;GAAE,MAAM;GAAc,MAAM;GAAW,SAAS;GAAO,GAAG;EAAY,CACxE;CACF;CAEA,MAAM,MACJ,QAAQ,gBAAgB,OACpB,OACA,QAAQ,gBAAgB,SACtB,SACA,aAAa,WACX,UAAU,OAAO,IACjB;CACV,MAAM,cAAc,QAAQ,SAAY,KAAK,YAAY,QAAQ,KAAK,WAAW,GAAG;CACpF,MAAM,SAAS,KAAK,aAAa,QAAQ,SAAS,QAAQ,OAAO,MAAM,GAAG,CAAC;CAC3E,IAAI,MAAM;CACV,IAAI,SAAS;CAEb,KAAK,MAAM,SAAS,QAAQ;EAC1B,OAAO,QAAQ,MAAM,QAAQ,MAAM,KAAK,IAAI;EAC5C,SAAS,MAAM;CACjB;CAEA,OAAO,QAAQ,MAAM,MAAM;CAE3B,OAAO;EACL,SAAS;EACT,cAAc,OAAO;EACrB,GAAI,QAAQ,EAAE,WAAW,QAAQ,KAAiC,IAAI,CAAC;CACzE;AACF"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region ../../packages/ai-workspace/src/edit/match.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* How `edit_file` / `multi_edit` find `oldString` in a file.
|
|
4
|
+
*
|
|
5
|
+
* - `"exact"` — the text must appear byte for byte.
|
|
6
|
+
* - `"tiered"` (default) — try the exact text first, then fall back, in order,
|
|
7
|
+
* to the same text with line endings normalised, with look-alike Unicode
|
|
8
|
+
* punctuation folded (curly quotes, dashes, non-breaking spaces), and
|
|
9
|
+
* finally line by line ignoring trailing whitespace. The first tier that
|
|
10
|
+
* finds the text wins.
|
|
11
|
+
*/
|
|
12
|
+
export type WorkspaceEditMatching = "exact" | "tiered";
|
|
13
|
+
/** The tier that located an edit's text. */
|
|
14
|
+
export type WorkspaceEditMatchTier = "exact" | "unicode" | "whitespace";
|
|
15
|
+
//#endregion
|
|
16
|
+
//# sourceMappingURL=match.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"match.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/edit/match.ts"],"mappings":";;;;;;;;;;;YAUY;;YAGA"}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
//#region ../../packages/ai-workspace/src/edit/match.ts
|
|
2
|
+
/** Most lines of file text a miss hint shows. */
|
|
3
|
+
const MAX_HINT_LINES = 12;
|
|
4
|
+
/** Most characters of one line a miss hint shows. */
|
|
5
|
+
const MAX_HINT_LINE_CHARS = 200;
|
|
6
|
+
const FOLD_MAP = {
|
|
7
|
+
"‘": "'",
|
|
8
|
+
"’": "'",
|
|
9
|
+
"‚": "'",
|
|
10
|
+
"‛": "'",
|
|
11
|
+
"“": "\"",
|
|
12
|
+
"”": "\"",
|
|
13
|
+
"„": "\"",
|
|
14
|
+
"‟": "\"",
|
|
15
|
+
"‐": "-",
|
|
16
|
+
"‑": "-",
|
|
17
|
+
"‒": "-",
|
|
18
|
+
"–": "-",
|
|
19
|
+
"—": "-",
|
|
20
|
+
"―": "-",
|
|
21
|
+
"−": "-",
|
|
22
|
+
"\xA0": " ",
|
|
23
|
+
" ": " ",
|
|
24
|
+
" ": " ",
|
|
25
|
+
" ": " ",
|
|
26
|
+
" ": " ",
|
|
27
|
+
" ": " ",
|
|
28
|
+
" ": " "
|
|
29
|
+
};
|
|
30
|
+
/** Fold look-alike characters one for one, so string indexes stay aligned. */
|
|
31
|
+
function foldUnicode(value) {
|
|
32
|
+
let out = "";
|
|
33
|
+
for (const char of value) out += FOLD_MAP[char] ?? char;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** Every non-overlapping occurrence of `needle` in `haystack`. */
|
|
37
|
+
function findAll(haystack, needle) {
|
|
38
|
+
const ranges = [];
|
|
39
|
+
if (needle === "") return ranges;
|
|
40
|
+
let from = 0;
|
|
41
|
+
for (;;) {
|
|
42
|
+
const index = haystack.indexOf(needle, from);
|
|
43
|
+
if (index === -1) return ranges;
|
|
44
|
+
ranges.push({
|
|
45
|
+
start: index,
|
|
46
|
+
end: index + needle.length
|
|
47
|
+
});
|
|
48
|
+
from = index + needle.length;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The file's dominant line ending. */
|
|
52
|
+
function detectEol(content) {
|
|
53
|
+
const crlf = content.split("\r\n").length - 1;
|
|
54
|
+
return crlf > content.split("\n").length - 1 - crlf ? "\r\n" : "\n";
|
|
55
|
+
}
|
|
56
|
+
/** Convert any line endings in `text` to `eol`. */
|
|
57
|
+
function withEol(text, eol) {
|
|
58
|
+
const normalised = text.replace(/\r\n/g, "\n");
|
|
59
|
+
return eol === "\n" ? normalised : normalised.replace(/\n/g, "\r\n");
|
|
60
|
+
}
|
|
61
|
+
/** An LF view of `content` plus the original index of each view character. */
|
|
62
|
+
function lfView(content) {
|
|
63
|
+
if (!content.includes("\r\n")) return {
|
|
64
|
+
view: content,
|
|
65
|
+
map: null
|
|
66
|
+
};
|
|
67
|
+
let view = "";
|
|
68
|
+
const map = [];
|
|
69
|
+
for (let index = 0; index < content.length; index++) {
|
|
70
|
+
if (content[index] === "\r" && content[index + 1] === "\n") continue;
|
|
71
|
+
map.push(content[index] === "\n" && content[index - 1] === "\r" ? index - 1 : index);
|
|
72
|
+
view += content[index];
|
|
73
|
+
}
|
|
74
|
+
map.push(content.length);
|
|
75
|
+
return {
|
|
76
|
+
view,
|
|
77
|
+
map
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Line-based search ignoring trailing whitespace, on already folded text. */
|
|
81
|
+
function findByLines(view, needle) {
|
|
82
|
+
const needleLines = needle.split("\n");
|
|
83
|
+
const endsWithBreak = needleLines.length > 1 && needleLines[needleLines.length - 1] === "";
|
|
84
|
+
if (endsWithBreak) needleLines.pop();
|
|
85
|
+
const wanted = needleLines.map((line) => line.trimEnd());
|
|
86
|
+
if (wanted.every((line) => line === "")) return [];
|
|
87
|
+
const lines = view.split("\n");
|
|
88
|
+
const starts = [];
|
|
89
|
+
let offset = 0;
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
starts.push(offset);
|
|
92
|
+
offset += line.length + 1;
|
|
93
|
+
}
|
|
94
|
+
const ranges = [];
|
|
95
|
+
for (let index = 0; index + wanted.length <= lines.length;) {
|
|
96
|
+
let same = true;
|
|
97
|
+
for (let k = 0; k < wanted.length; k++) if ((lines[index + k] ?? "").trimEnd() !== wanted[k]) {
|
|
98
|
+
same = false;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (!same) {
|
|
102
|
+
index++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const last = index + wanted.length - 1;
|
|
106
|
+
let end = (starts[last] ?? 0) + (lines[last] ?? "").length;
|
|
107
|
+
if (endsWithBreak && view[end] === "\n") end++;
|
|
108
|
+
ranges.push({
|
|
109
|
+
start: starts[index] ?? 0,
|
|
110
|
+
end
|
|
111
|
+
});
|
|
112
|
+
index += wanted.length;
|
|
113
|
+
}
|
|
114
|
+
return ranges;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Find `oldString` in `content`. `"exact"` only looks for the text as given;
|
|
118
|
+
* `"tiered"` walks the tiers described on {@link WorkspaceEditMatching} and
|
|
119
|
+
* stops at the first one with a match. The ranges are always in the original
|
|
120
|
+
* content, whatever view found them.
|
|
121
|
+
*/
|
|
122
|
+
function locateEdit(content, oldString, matching) {
|
|
123
|
+
const exact = findAll(content, oldString);
|
|
124
|
+
if (exact.length > 0 || matching === "exact") return {
|
|
125
|
+
tier: exact.length > 0 ? "exact" : null,
|
|
126
|
+
ranges: exact,
|
|
127
|
+
viaView: false
|
|
128
|
+
};
|
|
129
|
+
const { view, map } = lfView(content);
|
|
130
|
+
const needle = oldString.replace(/\r\n/g, "\n");
|
|
131
|
+
const back = (ranges) => map === null ? ranges : ranges.map((range) => ({
|
|
132
|
+
start: map[range.start] ?? content.length,
|
|
133
|
+
end: map[range.end] ?? content.length
|
|
134
|
+
}));
|
|
135
|
+
if (map !== null || needle !== oldString) {
|
|
136
|
+
const plain = findAll(view, needle);
|
|
137
|
+
if (plain.length > 0) return {
|
|
138
|
+
tier: "exact",
|
|
139
|
+
ranges: back(plain),
|
|
140
|
+
viaView: true
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const foldedView = foldUnicode(view);
|
|
144
|
+
const foldedNeedle = foldUnicode(needle);
|
|
145
|
+
const folded = findAll(foldedView, foldedNeedle);
|
|
146
|
+
if (folded.length > 0) return {
|
|
147
|
+
tier: "unicode",
|
|
148
|
+
ranges: back(folded),
|
|
149
|
+
viaView: true
|
|
150
|
+
};
|
|
151
|
+
const lined = findByLines(foldedView, foldedNeedle);
|
|
152
|
+
if (lined.length > 0) return {
|
|
153
|
+
tier: "whitespace",
|
|
154
|
+
ranges: back(lined),
|
|
155
|
+
viaView: true
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
tier: null,
|
|
159
|
+
ranges: [],
|
|
160
|
+
viaView: false
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function clipLine(line) {
|
|
164
|
+
const flat = line.replace(/\r$/, "");
|
|
165
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
|
166
|
+
}
|
|
167
|
+
function tokens(line) {
|
|
168
|
+
return new Set(line.match(/[A-Za-z0-9_$]+/g) ?? []);
|
|
169
|
+
}
|
|
170
|
+
function similarity(left, right) {
|
|
171
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
172
|
+
let shared = 0;
|
|
173
|
+
for (const token of left) if (right.has(token)) shared++;
|
|
174
|
+
return shared / (left.size + right.size - shared);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Explain a miss: either `newString` is already in the file (the edit was
|
|
178
|
+
* probably applied), or the file text closest to `oldString`, at most
|
|
179
|
+
* {@link MAX_HINT_LINES} lines of {@link MAX_HINT_LINE_CHARS} characters.
|
|
180
|
+
* `undefined` when nothing is close.
|
|
181
|
+
*/
|
|
182
|
+
function describeMiss(content, oldString, newString, maxLines = 12) {
|
|
183
|
+
if (maxLines <= 0) return;
|
|
184
|
+
const lines = content.split("\n");
|
|
185
|
+
const present = newString.trim() === "" ? -1 : content.indexOf(newString);
|
|
186
|
+
if (present !== -1 && newString !== oldString) return `newString already present in the file (line ${content.slice(0, present).split("\n").length}); the edit may already have been applied.`;
|
|
187
|
+
const fileTokens = lines.map((line) => tokens(line));
|
|
188
|
+
const wanted = oldString.split("\n");
|
|
189
|
+
let best = {
|
|
190
|
+
score: 0,
|
|
191
|
+
fileLine: -1,
|
|
192
|
+
offset: 0
|
|
193
|
+
};
|
|
194
|
+
wanted.forEach((text, offset) => {
|
|
195
|
+
const own = tokens(text);
|
|
196
|
+
fileTokens.forEach((candidate, fileLine) => {
|
|
197
|
+
const score = similarity(own, candidate);
|
|
198
|
+
if (score > best.score) best = {
|
|
199
|
+
score,
|
|
200
|
+
fileLine,
|
|
201
|
+
offset
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
if (best.fileLine === -1 || best.score < .5) return;
|
|
206
|
+
const first = Math.max(0, best.fileLine - best.offset);
|
|
207
|
+
const count = Math.min(maxLines, 12, Math.max(1, wanted.length));
|
|
208
|
+
const shown = lines.slice(first, first + count).map((line, index) => `${first + index + 1}: ${clipLine(line)}`);
|
|
209
|
+
return `Closest text in the file (lines ${first + 1}-${first + shown.length}):\n${shown.join("\n")}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
//#endregion
|
|
213
|
+
export { describeMiss, detectEol, foldUnicode, locateEdit, withEol };
|
|
214
|
+
//# sourceMappingURL=match.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"match.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/edit/match.ts"],"sourcesContent":["/**\n * How `edit_file` / `multi_edit` find `oldString` in a file.\n *\n * - `\"exact\"` — the text must appear byte for byte.\n * - `\"tiered\"` (default) — try the exact text first, then fall back, in order,\n * to the same text with line endings normalised, with look-alike Unicode\n * punctuation folded (curly quotes, dashes, non-breaking spaces), and\n * finally line by line ignoring trailing whitespace. The first tier that\n * finds the text wins.\n */\nexport type WorkspaceEditMatching = \"exact\" | \"tiered\";\n\n/** The tier that located an edit's text. */\nexport type WorkspaceEditMatchTier = \"exact\" | \"unicode\" | \"whitespace\";\n\n/** Half-open character range `[start, end)` in the original file content. */\nexport interface EditRange {\n start: number;\n end: number;\n}\n\n/** Where `oldString` was found. */\nexport interface EditLocation {\n /** The tier that matched; `null` when none did. */\n tier: WorkspaceEditMatchTier | null;\n /** Non-overlapping ranges in the original content, ascending. */\n ranges: EditRange[];\n /** True when the match went through the line-ending-normalised view. */\n viaView: boolean;\n}\n\n/** Most lines of file text a miss hint shows. */\nexport const MAX_HINT_LINES = 12;\n/** Most characters of one line a miss hint shows. */\nexport const MAX_HINT_LINE_CHARS = 200;\n\nconst FOLD_MAP: Record<string, string> = {\n \"‘\": \"'\",\n \"’\": \"'\",\n \"‚\": \"'\",\n \"‛\": \"'\",\n \"“\": '\"',\n \"”\": '\"',\n \"„\": '\"',\n \"‟\": '\"',\n \"‐\": \"-\",\n \"‑\": \"-\",\n \"‒\": \"-\",\n \"–\": \"-\",\n \"—\": \"-\",\n \"―\": \"-\",\n \"−\": \"-\",\n \" \": \" \",\n \" \": \" \",\n \" \": \" \",\n \" \": \" \",\n \" \": \" \",\n \" \": \" \",\n \" \": \" \",\n};\n\n/** Fold look-alike characters one for one, so string indexes stay aligned. */\nexport function foldUnicode(value: string): string {\n let out = \"\";\n\n for (const char of value) {\n out += FOLD_MAP[char] ?? char;\n }\n\n return out;\n}\n\n/** Every non-overlapping occurrence of `needle` in `haystack`. */\nfunction findAll(haystack: string, needle: string): EditRange[] {\n const ranges: EditRange[] = [];\n\n if (needle === \"\") {\n return ranges;\n }\n\n let from = 0;\n\n for (;;) {\n const index = haystack.indexOf(needle, from);\n\n if (index === -1) {\n return ranges;\n }\n\n ranges.push({ start: index, end: index + needle.length });\n from = index + needle.length;\n }\n}\n\n/** The file's dominant line ending. */\nexport function detectEol(content: string): \"\\n\" | \"\\r\\n\" {\n const crlf = content.split(\"\\r\\n\").length - 1;\n const lf = content.split(\"\\n\").length - 1 - crlf;\n\n return crlf > lf ? \"\\r\\n\" : \"\\n\";\n}\n\n/** Convert any line endings in `text` to `eol`. */\nexport function withEol(text: string, eol: \"\\n\" | \"\\r\\n\"): string {\n const normalised = text.replace(/\\r\\n/g, \"\\n\");\n\n return eol === \"\\n\" ? normalised : normalised.replace(/\\n/g, \"\\r\\n\");\n}\n\n/** An LF view of `content` plus the original index of each view character. */\nfunction lfView(content: string): { view: string; map: number[] | null } {\n if (!content.includes(\"\\r\\n\")) {\n return { view: content, map: null };\n }\n\n let view = \"\";\n const map: number[] = [];\n\n for (let index = 0; index < content.length; index++) {\n if (content[index] === \"\\r\" && content[index + 1] === \"\\n\") {\n continue;\n }\n\n // A `\\n` that came from `\\r\\n` points at the `\\r`, so a range starting\n // on it takes the whole line break.\n map.push(content[index] === \"\\n\" && content[index - 1] === \"\\r\" ? index - 1 : index);\n view += content[index];\n }\n\n map.push(content.length);\n\n return { view, map };\n}\n\n/** Line-based search ignoring trailing whitespace, on already folded text. */\nfunction findByLines(view: string, needle: string): EditRange[] {\n const needleLines = needle.split(\"\\n\");\n const endsWithBreak = needleLines.length > 1 && needleLines[needleLines.length - 1] === \"\";\n\n if (endsWithBreak) {\n needleLines.pop();\n }\n\n const wanted = needleLines.map((line) => line.trimEnd());\n\n if (wanted.every((line) => line === \"\")) {\n return [];\n }\n\n const lines = view.split(\"\\n\");\n const starts: number[] = [];\n let offset = 0;\n\n for (const line of lines) {\n starts.push(offset);\n offset += line.length + 1;\n }\n\n const ranges: EditRange[] = [];\n\n for (let index = 0; index + wanted.length <= lines.length;) {\n let same = true;\n\n for (let k = 0; k < wanted.length; k++) {\n if ((lines[index + k] ?? \"\").trimEnd() !== wanted[k]) {\n same = false;\n\n break;\n }\n }\n\n if (!same) {\n index++;\n\n continue;\n }\n\n const last = index + wanted.length - 1;\n let end = (starts[last] ?? 0) + (lines[last] ?? \"\").length;\n\n if (endsWithBreak && view[end] === \"\\n\") {\n end++;\n }\n\n ranges.push({ start: starts[index] ?? 0, end });\n index += wanted.length;\n }\n\n return ranges;\n}\n\n/**\n * Find `oldString` in `content`. `\"exact\"` only looks for the text as given;\n * `\"tiered\"` walks the tiers described on {@link WorkspaceEditMatching} and\n * stops at the first one with a match. The ranges are always in the original\n * content, whatever view found them.\n */\nexport function locateEdit(\n content: string,\n oldString: string,\n matching: WorkspaceEditMatching,\n): EditLocation {\n const exact = findAll(content, oldString);\n\n if (exact.length > 0 || matching === \"exact\") {\n return { tier: exact.length > 0 ? \"exact\" : null, ranges: exact, viaView: false };\n }\n\n const { view, map } = lfView(content);\n const needle = oldString.replace(/\\r\\n/g, \"\\n\");\n const back = (ranges: EditRange[]): EditRange[] =>\n map === null\n ? ranges\n : ranges.map((range) => ({\n start: map[range.start] ?? content.length,\n end: map[range.end] ?? content.length,\n }));\n\n if (map !== null || needle !== oldString) {\n const plain = findAll(view, needle);\n\n if (plain.length > 0) {\n return { tier: \"exact\", ranges: back(plain), viaView: true };\n }\n }\n\n const foldedView = foldUnicode(view);\n const foldedNeedle = foldUnicode(needle);\n const folded = findAll(foldedView, foldedNeedle);\n\n if (folded.length > 0) {\n return { tier: \"unicode\", ranges: back(folded), viaView: true };\n }\n\n const lined = findByLines(foldedView, foldedNeedle);\n\n if (lined.length > 0) {\n return { tier: \"whitespace\", ranges: back(lined), viaView: true };\n }\n\n return { tier: null, ranges: [], viaView: false };\n}\n\nfunction clipLine(line: string): string {\n const flat = line.replace(/\\r$/, \"\");\n\n return flat.length > MAX_HINT_LINE_CHARS ? `${flat.slice(0, MAX_HINT_LINE_CHARS)}…` : flat;\n}\n\nfunction tokens(line: string): Set<string> {\n return new Set(line.match(/[A-Za-z0-9_$]+/g) ?? []);\n}\n\nfunction similarity(left: Set<string>, right: Set<string>): number {\n if (left.size === 0 || right.size === 0) {\n return 0;\n }\n\n let shared = 0;\n\n for (const token of left) {\n if (right.has(token)) {\n shared++;\n }\n }\n\n return shared / (left.size + right.size - shared);\n}\n\n/**\n * Explain a miss: either `newString` is already in the file (the edit was\n * probably applied), or the file text closest to `oldString`, at most\n * {@link MAX_HINT_LINES} lines of {@link MAX_HINT_LINE_CHARS} characters.\n * `undefined` when nothing is close.\n */\nexport function describeMiss(\n content: string,\n oldString: string,\n newString: string,\n maxLines: number = MAX_HINT_LINES,\n): string | undefined {\n if (maxLines <= 0) {\n return undefined;\n }\n\n const lines = content.split(\"\\n\");\n const present = newString.trim() === \"\" ? -1 : content.indexOf(newString);\n\n if (present !== -1 && newString !== oldString) {\n const line = content.slice(0, present).split(\"\\n\").length;\n\n return `newString already present in the file (line ${line}); the edit may already have been applied.`;\n }\n\n const fileTokens = lines.map((line) => tokens(line));\n const wanted = oldString.split(\"\\n\");\n let best = { score: 0, fileLine: -1, offset: 0 };\n\n wanted.forEach((text, offset) => {\n const own = tokens(text);\n\n fileTokens.forEach((candidate, fileLine) => {\n const score = similarity(own, candidate);\n\n if (score > best.score) {\n best = { score, fileLine, offset };\n }\n });\n });\n\n if (best.fileLine === -1 || best.score < 0.5) {\n return undefined;\n }\n\n const first = Math.max(0, best.fileLine - best.offset);\n const count = Math.min(maxLines, MAX_HINT_LINES, Math.max(1, wanted.length));\n const shown = lines\n .slice(first, first + count)\n .map((line, index) => `${first + index + 1}: ${clipLine(line)}`);\n\n return `Closest text in the file (lines ${first + 1}-${first + shown.length}):\\n${shown.join(\"\\n\")}`;\n}\n"],"mappings":"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"}
|
package/esm/errors.d.mts
CHANGED
|
@@ -38,8 +38,11 @@ export type WorkspacePolicyViolation = "path-escape" | "denied-command" | "unsaf
|
|
|
38
38
|
* so the line is refused rather than guessed at.
|
|
39
39
|
* - `"path-escape"` — shell mode: a redirection target or `cd` directory
|
|
40
40
|
* resolves outside the workspace jail or hits `denyPaths`.
|
|
41
|
+
* - `"approval-required"` — a command matches an `ask` rule in
|
|
42
|
+
* `shell.rules` and the policy has no `shell.onAsk` to decide on it.
|
|
43
|
+
* - `"approval-denied"` — `shell.onAsk` refused the command (or threw).
|
|
41
44
|
*/
|
|
42
|
-
export type WorkspaceShellRefusalReason = "metacharacter" | "unbalanced-quotes" | "denied-executable" | "not-allowed" | "denied-pattern" | "nested-command" | "unsupported-syntax" | "path-escape";
|
|
45
|
+
export type WorkspaceShellRefusalReason = "metacharacter" | "unbalanced-quotes" | "denied-executable" | "not-allowed" | "denied-pattern" | "nested-command" | "unsupported-syntax" | "path-escape" | "approval-required" | "approval-denied";
|
|
43
46
|
/**
|
|
44
47
|
* Options for {@link WorkspacePolicyError} — the structured `type`
|
|
45
48
|
* discriminator plus, where relevant, the offending path or command for
|
|
@@ -132,6 +135,8 @@ export type WorkspaceEditErrorOptions = AIErrorOptions & {
|
|
|
132
135
|
actualHash?: string;
|
|
133
136
|
/** 0-based index of the failing edit, for a `multiEdit` rejection. */
|
|
134
137
|
editIndex?: number;
|
|
138
|
+
/** For `"not-found"`: the nearby file text, or a note that `newString` is already present. */
|
|
139
|
+
hint?: string;
|
|
135
140
|
/** 0-based index of the hunk that failed, for an `applyPatch` rejection. */
|
|
136
141
|
hunkIndex?: number;
|
|
137
142
|
};
|
|
@@ -165,6 +170,12 @@ export declare class WorkspaceEditError extends AIError {
|
|
|
165
170
|
* Nothing was written: a multi-edit applies all of its edits or none.
|
|
166
171
|
*/
|
|
167
172
|
readonly editIndex?: number;
|
|
173
|
+
/**
|
|
174
|
+
* For a `"not-found"` rejection, the file text closest to `oldString` (at
|
|
175
|
+
* most 12 lines of 200 characters) or a note that `newString` is already
|
|
176
|
+
* present. Absent when nothing is close.
|
|
177
|
+
*/
|
|
178
|
+
readonly hint?: string;
|
|
168
179
|
/**
|
|
169
180
|
* For an `applyPatch` rejection, the 0-based index of the hunk that failed
|
|
170
181
|
* within its file. Nothing was written.
|
package/esm/errors.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.mts","names":[],"sources":["../../../../../../../packages/ai-workspace/src/errors.ts"],"mappings":";;;;;;;;;;;;;;;YAeY
|
|
1
|
+
{"version":3,"file":"errors.d.mts","names":[],"sources":["../../../../../../../packages/ai-workspace/src/errors.ts"],"mappings":";;;;;;;;;;;;;;;YAeY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA8BA;;;;;;YAiBA,8BAA8B;;EAExC,MAAM;;EAEN;;EAEA;;EAEA;;EAEA,SAAS;;EAET;;;;;;;;;;;;;;;;;;;qBAoBW,6BAA6B;;WAExB,MAAM;;WAEN;;WAEA;;WAEA;;;;;;WAMA,SAAS;;WAET;EAEhB,YAAmB,iBAAiB,SAAS;;;;;;;;;;;;;;;;;;;;YA+BnC;;;;;;YAeA,4BAA4B;;EAEtC,MAAM;;EAEN;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;;;qBAiBW,2BAA2B;;WAEtB,MAAM;;WAEN;;WAEA;;WAEA;;WAEA;;;;;WAKA;;;;;;WAMA;;;;;WAKA;EAEhB,YAAmB,iBAAiB,SAAS;;;;;;;;;YAsBnC;;YAGA,2BAA2B;;EAErC,MAAM;;EAEN;;;;;;;;;;;;qBAaW,0BAA0B;;WAErB,MAAM;;WAEN;EAEhB,YAAmB,iBAAiB,SAAS"}
|
package/esm/errors.mjs
CHANGED
package/esm/errors.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.mjs","names":[],"sources":["../../../../../../../packages/ai-workspace/src/errors.ts"],"sourcesContent":["import { AIError, type AIErrorOptions } from \"@warlock.js/ai\";\n\n/**\n * Why a workspace policy check rejected an operation.\n *\n * - `\"path-escape\"` — a resolved path fell outside the `cwd` jail (or an\n * `allowPaths` root), or matched a `denyPaths` glob.\n * - `\"denied-command\"` — the shell policy refused a command line (`reason`\n * says why: an executable outside `shell.allow` or inside `shell.deny`, a\n * metacharacter in argv mode, a redirection outside the jail in shell\n * mode, …), or a mutating method was called on a read-only projection.\n * - `\"unsafe-pattern\"` — a `grep` pattern was too long or matched a\n * catastrophic-backtracking shape (nested quantifiers) that could hang\n * the process (ReDoS).\n */\nexport type WorkspacePolicyViolation = \"path-escape\" | \"denied-command\" | \"unsafe-pattern\";\n\n/**\n * Why the shell policy refused a command. Set on every\n * {@link WorkspacePolicyError} of type `\"denied-command\"` raised for a\n * command line, so a host can phrase the refusal itself instead of parsing\n * the message.\n *\n * - `\"metacharacter\"` — argv mode (the default `interpreter: \"none\"`) met an\n * unquoted shell operator (`&&`, `;`, `|`, `>`, `$`, backticks, …). The\n * message suggests how to split the line into separate commands.\n * - `\"unbalanced-quotes\"` — a quote is never closed.\n * - `\"denied-executable\"` — a command's executable is listed in `shell.deny`.\n * - `\"not-allowed\"` — a command's executable is not in `shell.allow`, the\n * `shell.allowCommand` hook refused it, the workspace has no shell policy,\n * the line is empty, or background commands are not enabled.\n * - `\"denied-pattern\"` — a command matched one of `shell.denyPatterns`.\n * - `\"nested-command\"` — a command runs another command the policy cannot\n * inspect (`bash -c …`, `eval`, `xargs`, `sudo`, a wrapper with options the\n * policy cannot read), while the policy has deny rules it would bypass.\n * - `\"unsupported-syntax\"` — shell mode (`interpreter: \"sh\"`) met syntax the\n * policy does not parse with confidence (compound commands, arithmetic,\n * brace expansion, background `&`, a command name decided at run time, …),\n * so the line is refused rather than guessed at.\n * - `\"path-escape\"` — shell mode: a redirection target or `cd` directory\n * resolves outside the workspace jail or hits `denyPaths`.\n */\nexport type WorkspaceShellRefusalReason =\n | \"metacharacter\"\n | \"unbalanced-quotes\"\n | \"denied-executable\"\n | \"not-allowed\"\n | \"denied-pattern\"\n | \"nested-command\"\n | \"unsupported-syntax\"\n | \"path-escape\";\n\n/**\n * Options for {@link WorkspacePolicyError} — the structured `type`\n * discriminator plus, where relevant, the offending path or command for\n * branchable diagnostics without parsing the message.\n */\nexport type WorkspacePolicyErrorOptions = AIErrorOptions & {\n /** Which policy rule was violated. */\n type: WorkspacePolicyViolation;\n /** The offending workspace-relative path (for `\"path-escape\"`). */\n path?: string;\n /** The offending command line (for `\"denied-command\"`). */\n command?: string;\n /** The offending regex pattern (for `\"unsafe-pattern\"`). */\n pattern?: string;\n /** Why the shell policy refused the command (for `\"denied-command\"`). */\n reason?: WorkspaceShellRefusalReason;\n /** The executable the refusal is about, when there is one. */\n executable?: string;\n};\n\n/**\n * The workspace policy engine refused an operation — a path escaped the\n * jail (or hit a deny glob), or a shell command's executable was not\n * allowed.\n *\n * **Surface.** This is returned to the agent as tool-error *data*, never\n * a thrown run-killer — the agent reads the failure and self-corrects.\n * Extends the framework `AIError` (category `\"tool\"`, code\n * `TOOL_EXEC_FAILED`) so it flows through the same typed error contract\n * as every other AI error; branch on `error.type` for the specific\n * violation.\n *\n * @example\n * if (error instanceof WorkspacePolicyError && error.type === \"denied-command\") {\n * console.warn(`Blocked command: ${error.command}`);\n * }\n */\nexport class WorkspacePolicyError extends AIError {\n /** Which policy rule was violated. */\n public readonly type: WorkspacePolicyViolation;\n /** The offending path, when the violation was a path escape. */\n public readonly path?: string;\n /** The offending command, when the violation was a denied command. */\n public readonly command?: string;\n /** The offending regex pattern, when the violation was `\"unsafe-pattern\"`. */\n public readonly pattern?: string;\n /**\n * Why the shell policy refused a command line — set on every\n * `\"denied-command\"` refusal of a command (see\n * {@link WorkspaceShellRefusalReason}).\n */\n public readonly reason?: WorkspaceShellRefusalReason;\n /** The executable the shell refusal is about, when there is one. */\n public readonly executable?: string;\n\n public constructor(message: string, options: WorkspacePolicyErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspacePolicyError\";\n this.type = options.type;\n this.path = options.path;\n this.command = options.command;\n this.pattern = options.pattern;\n this.reason = options.reason;\n this.executable = options.executable;\n }\n}\n\n/**\n * Why an edit was rejected.\n *\n * - `\"not-found\"` — the `oldString` did not appear in the file.\n * - `\"not-unique\"` — `oldString` matched more than once and `replaceAll`\n * was not set, so the edit is ambiguous.\n * - `\"stale-hash\"` — the file's current hash did not match the supplied\n * `expectHash`; the file changed since it was read.\n * - `\"patch-invalid\"` — `applyPatch` was given text that is not a usable\n * unified diff (or one that is too large).\n * - `\"hunk-failed\"` — a patch hunk's context is not in the file; `hunkIndex`\n * names it.\n * - `\"binary-file\"` — the patch, or a file it touches, is binary.\n * - `\"file-exists\"` — the patch creates (or renames onto) a path that\n * already exists.\n * - `\"file-missing\"` — the patch modifies, renames or deletes a path that\n * does not exist.\n */\nexport type WorkspaceEditFailure =\n | \"not-found\"\n | \"not-unique\"\n | \"stale-hash\"\n | \"patch-invalid\"\n | \"hunk-failed\"\n | \"binary-file\"\n | \"file-exists\"\n | \"file-missing\";\n\n/**\n * Options for {@link WorkspaceEditError} — the structured `type`\n * discriminator plus optional match-count / hash context for the\n * `\"not-unique\"` and `\"stale-hash\"` cases.\n */\nexport type WorkspaceEditErrorOptions = AIErrorOptions & {\n /** Why the edit was rejected. */\n type: WorkspaceEditFailure;\n /** The edited file's workspace-relative path. */\n path: string;\n /** How many times `oldString` matched (for `\"not-unique\"`). */\n matches?: number;\n /** The hash the caller expected (for `\"stale-hash\"`). */\n expectedHash?: string;\n /** The file's actual current hash (for `\"stale-hash\"`). */\n actualHash?: string;\n /** 0-based index of the failing edit, for a `multiEdit` rejection. */\n editIndex?: number;\n /** 0-based index of the hunk that failed, for an `applyPatch` rejection. */\n hunkIndex?: number;\n};\n\n/**\n * An `editFile` operation was rejected by the read-before-edit guard:\n * the `oldString` was absent, matched non-uniquely without `replaceAll`,\n * or the file's hash no longer matched the supplied `expectHash`.\n *\n * **Surface.** Like {@link WorkspacePolicyError}, returned to the agent\n * as tool-error *data* so it can re-read and retry. Extends `AIError`\n * (category `\"tool\"`, code `TOOL_EXEC_FAILED`); branch on `error.type`.\n *\n * @example\n * if (error instanceof WorkspaceEditError && error.type === \"stale-hash\") {\n * // re-read the file and retry the edit with the fresh hash\n * }\n */\nexport class WorkspaceEditError extends AIError {\n /** Why the edit was rejected. */\n public readonly type: WorkspaceEditFailure;\n /** The edited file's workspace-relative path. */\n public readonly path: string;\n /** How many times `oldString` matched, for the `\"not-unique\"` case. */\n public readonly matches?: number;\n /** The hash the caller expected, for the `\"stale-hash\"` case. */\n public readonly expectedHash?: string;\n /** The file's actual current hash, for the `\"stale-hash\"` case. */\n public readonly actualHash?: string;\n /**\n * For a `multiEdit` rejection, the 0-based index of the edit that failed.\n * Nothing was written: a multi-edit applies all of its edits or none.\n */\n public readonly editIndex?: number;\n /**\n * For an `applyPatch` rejection, the 0-based index of the hunk that failed\n * within its file. Nothing was written.\n */\n public readonly hunkIndex?: number;\n\n public constructor(message: string, options: WorkspaceEditErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspaceEditError\";\n this.type = options.type;\n this.path = options.path;\n this.matches = options.matches;\n this.expectedHash = options.expectedHash;\n this.actualHash = options.actualHash;\n this.editIndex = options.editIndex;\n this.hunkIndex = options.hunkIndex;\n }\n}\n\n/**\n * Why a background-command operation failed.\n *\n * - `\"not-found\"` — no background command with that id exists in this\n * workspace (it never existed, or it finished long enough ago to be\n * pruned).\n */\nexport type WorkspaceJobFailure = \"not-found\";\n\n/** Options for {@link WorkspaceJobError}. */\nexport type WorkspaceJobErrorOptions = AIErrorOptions & {\n /** Why the operation failed. */\n type: WorkspaceJobFailure;\n /** The background command id the caller asked for. */\n jobId: string;\n};\n\n/**\n * A `shell_output` / `shell_kill` call (or `shellOutput()` / `shellKill()`)\n * named a background command this workspace does not know. Returned to the\n * agent as tool-error data like the other workspace errors.\n *\n * @example\n * if (error instanceof WorkspaceJobError && error.type === \"not-found\") {\n * console.warn(`No background command ${error.jobId}`);\n * }\n */\nexport class WorkspaceJobError extends AIError {\n /** Why the operation failed. */\n public readonly type: WorkspaceJobFailure;\n /** The background command id the caller asked for. */\n public readonly jobId: string;\n\n public constructor(message: string, options: WorkspaceJobErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspaceJobError\";\n this.type = options.type;\n this.jobId = options.jobId;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAyFA,IAAa,uBAAb,cAA0C,QAAQ;CAkBhD,AAAO,YAAY,SAAiB,SAAsC;EACxE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ;EACpB,KAAK,UAAU,QAAQ;EACvB,KAAK,UAAU,QAAQ;EACvB,KAAK,SAAS,QAAQ;EACtB,KAAK,aAAa,QAAQ;CAC5B;AACF;;;;;;;;;;;;;;;AAkEA,IAAa,qBAAb,cAAwC,QAAQ;CAsB9C,AAAO,YAAY,SAAiB,SAAoC;EACtE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ;EACpB,KAAK,UAAU,QAAQ;EACvB,KAAK,eAAe,QAAQ;EAC5B,KAAK,aAAa,QAAQ;EAC1B,KAAK,YAAY,QAAQ;EACzB,KAAK,YAAY,QAAQ;CAC3B;AACF;;;;;;;;;;;AA6BA,IAAa,oBAAb,cAAuC,QAAQ;CAM7C,AAAO,YAAY,SAAiB,SAAmC;EACrE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,QAAQ,QAAQ;CACvB;AACF"}
|
|
1
|
+
{"version":3,"file":"errors.mjs","names":[],"sources":["../../../../../../../packages/ai-workspace/src/errors.ts"],"sourcesContent":["import { AIError, type AIErrorOptions } from \"@warlock.js/ai\";\n\n/**\n * Why a workspace policy check rejected an operation.\n *\n * - `\"path-escape\"` — a resolved path fell outside the `cwd` jail (or an\n * `allowPaths` root), or matched a `denyPaths` glob.\n * - `\"denied-command\"` — the shell policy refused a command line (`reason`\n * says why: an executable outside `shell.allow` or inside `shell.deny`, a\n * metacharacter in argv mode, a redirection outside the jail in shell\n * mode, …), or a mutating method was called on a read-only projection.\n * - `\"unsafe-pattern\"` — a `grep` pattern was too long or matched a\n * catastrophic-backtracking shape (nested quantifiers) that could hang\n * the process (ReDoS).\n */\nexport type WorkspacePolicyViolation = \"path-escape\" | \"denied-command\" | \"unsafe-pattern\";\n\n/**\n * Why the shell policy refused a command. Set on every\n * {@link WorkspacePolicyError} of type `\"denied-command\"` raised for a\n * command line, so a host can phrase the refusal itself instead of parsing\n * the message.\n *\n * - `\"metacharacter\"` — argv mode (the default `interpreter: \"none\"`) met an\n * unquoted shell operator (`&&`, `;`, `|`, `>`, `$`, backticks, …). The\n * message suggests how to split the line into separate commands.\n * - `\"unbalanced-quotes\"` — a quote is never closed.\n * - `\"denied-executable\"` — a command's executable is listed in `shell.deny`.\n * - `\"not-allowed\"` — a command's executable is not in `shell.allow`, the\n * `shell.allowCommand` hook refused it, the workspace has no shell policy,\n * the line is empty, or background commands are not enabled.\n * - `\"denied-pattern\"` — a command matched one of `shell.denyPatterns`.\n * - `\"nested-command\"` — a command runs another command the policy cannot\n * inspect (`bash -c …`, `eval`, `xargs`, `sudo`, a wrapper with options the\n * policy cannot read), while the policy has deny rules it would bypass.\n * - `\"unsupported-syntax\"` — shell mode (`interpreter: \"sh\"`) met syntax the\n * policy does not parse with confidence (compound commands, arithmetic,\n * brace expansion, background `&`, a command name decided at run time, …),\n * so the line is refused rather than guessed at.\n * - `\"path-escape\"` — shell mode: a redirection target or `cd` directory\n * resolves outside the workspace jail or hits `denyPaths`.\n * - `\"approval-required\"` — a command matches an `ask` rule in\n * `shell.rules` and the policy has no `shell.onAsk` to decide on it.\n * - `\"approval-denied\"` — `shell.onAsk` refused the command (or threw).\n */\nexport type WorkspaceShellRefusalReason =\n | \"metacharacter\"\n | \"unbalanced-quotes\"\n | \"denied-executable\"\n | \"not-allowed\"\n | \"denied-pattern\"\n | \"nested-command\"\n | \"unsupported-syntax\"\n | \"path-escape\"\n | \"approval-required\"\n | \"approval-denied\";\n\n/**\n * Options for {@link WorkspacePolicyError} — the structured `type`\n * discriminator plus, where relevant, the offending path or command for\n * branchable diagnostics without parsing the message.\n */\nexport type WorkspacePolicyErrorOptions = AIErrorOptions & {\n /** Which policy rule was violated. */\n type: WorkspacePolicyViolation;\n /** The offending workspace-relative path (for `\"path-escape\"`). */\n path?: string;\n /** The offending command line (for `\"denied-command\"`). */\n command?: string;\n /** The offending regex pattern (for `\"unsafe-pattern\"`). */\n pattern?: string;\n /** Why the shell policy refused the command (for `\"denied-command\"`). */\n reason?: WorkspaceShellRefusalReason;\n /** The executable the refusal is about, when there is one. */\n executable?: string;\n};\n\n/**\n * The workspace policy engine refused an operation — a path escaped the\n * jail (or hit a deny glob), or a shell command's executable was not\n * allowed.\n *\n * **Surface.** This is returned to the agent as tool-error *data*, never\n * a thrown run-killer — the agent reads the failure and self-corrects.\n * Extends the framework `AIError` (category `\"tool\"`, code\n * `TOOL_EXEC_FAILED`) so it flows through the same typed error contract\n * as every other AI error; branch on `error.type` for the specific\n * violation.\n *\n * @example\n * if (error instanceof WorkspacePolicyError && error.type === \"denied-command\") {\n * console.warn(`Blocked command: ${error.command}`);\n * }\n */\nexport class WorkspacePolicyError extends AIError {\n /** Which policy rule was violated. */\n public readonly type: WorkspacePolicyViolation;\n /** The offending path, when the violation was a path escape. */\n public readonly path?: string;\n /** The offending command, when the violation was a denied command. */\n public readonly command?: string;\n /** The offending regex pattern, when the violation was `\"unsafe-pattern\"`. */\n public readonly pattern?: string;\n /**\n * Why the shell policy refused a command line — set on every\n * `\"denied-command\"` refusal of a command (see\n * {@link WorkspaceShellRefusalReason}).\n */\n public readonly reason?: WorkspaceShellRefusalReason;\n /** The executable the shell refusal is about, when there is one. */\n public readonly executable?: string;\n\n public constructor(message: string, options: WorkspacePolicyErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspacePolicyError\";\n this.type = options.type;\n this.path = options.path;\n this.command = options.command;\n this.pattern = options.pattern;\n this.reason = options.reason;\n this.executable = options.executable;\n }\n}\n\n/**\n * Why an edit was rejected.\n *\n * - `\"not-found\"` — the `oldString` did not appear in the file.\n * - `\"not-unique\"` — `oldString` matched more than once and `replaceAll`\n * was not set, so the edit is ambiguous.\n * - `\"stale-hash\"` — the file's current hash did not match the supplied\n * `expectHash`; the file changed since it was read.\n * - `\"patch-invalid\"` — `applyPatch` was given text that is not a usable\n * unified diff (or one that is too large).\n * - `\"hunk-failed\"` — a patch hunk's context is not in the file; `hunkIndex`\n * names it.\n * - `\"binary-file\"` — the patch, or a file it touches, is binary.\n * - `\"file-exists\"` — the patch creates (or renames onto) a path that\n * already exists.\n * - `\"file-missing\"` — the patch modifies, renames or deletes a path that\n * does not exist.\n */\nexport type WorkspaceEditFailure =\n | \"not-found\"\n | \"not-unique\"\n | \"stale-hash\"\n | \"patch-invalid\"\n | \"hunk-failed\"\n | \"binary-file\"\n | \"file-exists\"\n | \"file-missing\";\n\n/**\n * Options for {@link WorkspaceEditError} — the structured `type`\n * discriminator plus optional match-count / hash context for the\n * `\"not-unique\"` and `\"stale-hash\"` cases.\n */\nexport type WorkspaceEditErrorOptions = AIErrorOptions & {\n /** Why the edit was rejected. */\n type: WorkspaceEditFailure;\n /** The edited file's workspace-relative path. */\n path: string;\n /** How many times `oldString` matched (for `\"not-unique\"`). */\n matches?: number;\n /** The hash the caller expected (for `\"stale-hash\"`). */\n expectedHash?: string;\n /** The file's actual current hash (for `\"stale-hash\"`). */\n actualHash?: string;\n /** 0-based index of the failing edit, for a `multiEdit` rejection. */\n editIndex?: number;\n /** For `\"not-found\"`: the nearby file text, or a note that `newString` is already present. */\n hint?: string;\n /** 0-based index of the hunk that failed, for an `applyPatch` rejection. */\n hunkIndex?: number;\n};\n\n/**\n * An `editFile` operation was rejected by the read-before-edit guard:\n * the `oldString` was absent, matched non-uniquely without `replaceAll`,\n * or the file's hash no longer matched the supplied `expectHash`.\n *\n * **Surface.** Like {@link WorkspacePolicyError}, returned to the agent\n * as tool-error *data* so it can re-read and retry. Extends `AIError`\n * (category `\"tool\"`, code `TOOL_EXEC_FAILED`); branch on `error.type`.\n *\n * @example\n * if (error instanceof WorkspaceEditError && error.type === \"stale-hash\") {\n * // re-read the file and retry the edit with the fresh hash\n * }\n */\nexport class WorkspaceEditError extends AIError {\n /** Why the edit was rejected. */\n public readonly type: WorkspaceEditFailure;\n /** The edited file's workspace-relative path. */\n public readonly path: string;\n /** How many times `oldString` matched, for the `\"not-unique\"` case. */\n public readonly matches?: number;\n /** The hash the caller expected, for the `\"stale-hash\"` case. */\n public readonly expectedHash?: string;\n /** The file's actual current hash, for the `\"stale-hash\"` case. */\n public readonly actualHash?: string;\n /**\n * For a `multiEdit` rejection, the 0-based index of the edit that failed.\n * Nothing was written: a multi-edit applies all of its edits or none.\n */\n public readonly editIndex?: number;\n /**\n * For a `\"not-found\"` rejection, the file text closest to `oldString` (at\n * most 12 lines of 200 characters) or a note that `newString` is already\n * present. Absent when nothing is close.\n */\n public readonly hint?: string;\n /**\n * For an `applyPatch` rejection, the 0-based index of the hunk that failed\n * within its file. Nothing was written.\n */\n public readonly hunkIndex?: number;\n\n public constructor(message: string, options: WorkspaceEditErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspaceEditError\";\n this.type = options.type;\n this.path = options.path;\n this.matches = options.matches;\n this.expectedHash = options.expectedHash;\n this.actualHash = options.actualHash;\n this.editIndex = options.editIndex;\n this.hint = options.hint;\n this.hunkIndex = options.hunkIndex;\n }\n}\n\n/**\n * Why a background-command operation failed.\n *\n * - `\"not-found\"` — no background command with that id exists in this\n * workspace (it never existed, or it finished long enough ago to be\n * pruned).\n */\nexport type WorkspaceJobFailure = \"not-found\";\n\n/** Options for {@link WorkspaceJobError}. */\nexport type WorkspaceJobErrorOptions = AIErrorOptions & {\n /** Why the operation failed. */\n type: WorkspaceJobFailure;\n /** The background command id the caller asked for. */\n jobId: string;\n};\n\n/**\n * A `shell_output` / `shell_kill` call (or `shellOutput()` / `shellKill()`)\n * named a background command this workspace does not know. Returned to the\n * agent as tool-error data like the other workspace errors.\n *\n * @example\n * if (error instanceof WorkspaceJobError && error.type === \"not-found\") {\n * console.warn(`No background command ${error.jobId}`);\n * }\n */\nexport class WorkspaceJobError extends AIError {\n /** Why the operation failed. */\n public readonly type: WorkspaceJobFailure;\n /** The background command id the caller asked for. */\n public readonly jobId: string;\n\n public constructor(message: string, options: WorkspaceJobErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspaceJobError\";\n this.type = options.type;\n this.jobId = options.jobId;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA8FA,IAAa,uBAAb,cAA0C,QAAQ;CAkBhD,AAAO,YAAY,SAAiB,SAAsC;EACxE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ;EACpB,KAAK,UAAU,QAAQ;EACvB,KAAK,UAAU,QAAQ;EACvB,KAAK,SAAS,QAAQ;EACtB,KAAK,aAAa,QAAQ;CAC5B;AACF;;;;;;;;;;;;;;;AAoEA,IAAa,qBAAb,cAAwC,QAAQ;CA4B9C,AAAO,YAAY,SAAiB,SAAoC;EACtE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ;EACpB,KAAK,UAAU,QAAQ;EACvB,KAAK,eAAe,QAAQ;EAC5B,KAAK,aAAa,QAAQ;EAC1B,KAAK,YAAY,QAAQ;EACzB,KAAK,OAAO,QAAQ;EACpB,KAAK,YAAY,QAAQ;CAC3B;AACF;;;;;;;;;;;AA6BA,IAAa,oBAAb,cAAuC,QAAQ;CAM7C,AAAO,YAAY,SAAiB,SAAmC;EACrE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,QAAQ,QAAQ;CACvB;AACF"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region ../../packages/ai-workspace/src/hooks/after-edit.ts
|
|
2
|
+
/** How long a hook may take when the policy sets no `afterEditTimeoutMs`. */
|
|
3
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 1e4;
|
|
4
|
+
/** Longest diagnostics text kept when the policy sets no `afterEditMaxChars`. */
|
|
5
|
+
const DEFAULT_MAX_CHARS = 4e3;
|
|
6
|
+
function wrap(text) {
|
|
7
|
+
return `[diagnostics]\n${text}\n[/diagnostics]`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Run the policy's `afterEdit` hook for one call and return the text to put
|
|
11
|
+
* on the result's `diagnostics`, or `undefined` when there is none. A throw
|
|
12
|
+
* or a timeout yields "diagnostics unavailable"; it never rejects.
|
|
13
|
+
*/
|
|
14
|
+
async function runAfterEdit(policy, event) {
|
|
15
|
+
const hook = policy.hooks?.afterEdit;
|
|
16
|
+
if (hook === void 0 || event.paths.length === 0) return;
|
|
17
|
+
const timeoutMs = policy.hooks?.afterEditTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
18
|
+
const maxChars = policy.hooks?.afterEditMaxChars ?? DEFAULT_MAX_CHARS;
|
|
19
|
+
let timer;
|
|
20
|
+
try {
|
|
21
|
+
const answer = await Promise.race([Promise.resolve().then(() => hook(event)), new Promise((_resolve, reject) => {
|
|
22
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("afterEdit hook timed out")), timeoutMs);
|
|
23
|
+
})]);
|
|
24
|
+
if (typeof answer !== "string" || answer.trim() === "") return;
|
|
25
|
+
const text = answer.trim();
|
|
26
|
+
return wrap(text.length > maxChars ? `${text.slice(0, maxChars)}\n[diagnostics truncated: showing ${maxChars} of ${text.length} characters]` : text);
|
|
27
|
+
} catch {
|
|
28
|
+
return wrap("diagnostics unavailable");
|
|
29
|
+
} finally {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { runAfterEdit };
|
|
36
|
+
//# sourceMappingURL=after-edit.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"after-edit.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/hooks/after-edit.ts"],"sourcesContent":["import type { WorkspaceAfterEditEvent, WorkspacePolicy } from \"../contracts\";\n\n/** How long a hook may take when the policy sets no `afterEditTimeoutMs`. */\nconst DEFAULT_HOOK_TIMEOUT_MS = 10_000;\n/** Longest diagnostics text kept when the policy sets no `afterEditMaxChars`. */\nconst DEFAULT_MAX_CHARS = 4_000;\n\nfunction wrap(text: string): string {\n return `[diagnostics]\\n${text}\\n[/diagnostics]`;\n}\n\n/**\n * Run the policy's `afterEdit` hook for one call and return the text to put\n * on the result's `diagnostics`, or `undefined` when there is none. A throw\n * or a timeout yields \"diagnostics unavailable\"; it never rejects.\n */\nexport async function runAfterEdit(\n policy: WorkspacePolicy,\n event: WorkspaceAfterEditEvent,\n): Promise<string | undefined> {\n const hook = policy.hooks?.afterEdit;\n\n if (hook === undefined || event.paths.length === 0) {\n return undefined;\n }\n\n const timeoutMs = policy.hooks?.afterEditTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;\n const maxChars = policy.hooks?.afterEditMaxChars ?? DEFAULT_MAX_CHARS;\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n try {\n const answer = await Promise.race([\n Promise.resolve().then(() => hook(event)),\n new Promise<never>((_resolve, reject) => {\n timer = setTimeout(() => reject(new Error(\"afterEdit hook timed out\")), timeoutMs);\n }),\n ]);\n\n if (typeof answer !== \"string\" || answer.trim() === \"\") {\n return undefined;\n }\n\n const text = answer.trim();\n\n return wrap(\n text.length > maxChars\n ? `${text.slice(0, maxChars)}\\n[diagnostics truncated: showing ${maxChars} of ${text.length} characters]`\n : text,\n );\n } catch {\n return wrap(\"diagnostics unavailable\");\n } finally {\n clearTimeout(timer);\n }\n}\n"],"mappings":";;AAGA,MAAM,0BAA0B;;AAEhC,MAAM,oBAAoB;AAE1B,SAAS,KAAK,MAAsB;CAClC,OAAO,kBAAkB,KAAK;AAChC;;;;;;AAOA,eAAsB,aACpB,QACA,OAC6B;CAC7B,MAAM,OAAO,OAAO,OAAO;CAE3B,IAAI,SAAS,UAAa,MAAM,MAAM,WAAW,GAC/C;CAGF,MAAM,YAAY,OAAO,OAAO,sBAAsB;CACtD,MAAM,WAAW,OAAO,OAAO,qBAAqB;CACpD,IAAI;CAEJ,IAAI;EACF,MAAM,SAAS,MAAM,QAAQ,KAAK,CAChC,QAAQ,QAAQ,CAAC,CAAC,WAAW,KAAK,KAAK,CAAC,GACxC,IAAI,SAAgB,UAAU,WAAW;GACvC,QAAQ,iBAAiB,uBAAO,IAAI,MAAM,0BAA0B,CAAC,GAAG,SAAS;EACnF,CAAC,CACH,CAAC;EAED,IAAI,OAAO,WAAW,YAAY,OAAO,KAAK,MAAM,IAClD;EAGF,MAAM,OAAO,OAAO,KAAK;EAEzB,OAAO,KACL,KAAK,SAAS,WACV,GAAG,KAAK,MAAM,GAAG,QAAQ,EAAE,oCAAoC,SAAS,MAAM,KAAK,OAAO,gBAC1F,IACN;CACF,QAAQ;EACN,OAAO,KAAK,yBAAyB;CACvC,UAAU;EACR,aAAa,KAAK;CACpB;AACF"}
|