pi-hashline-edit-pro 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/index.ts +52 -23
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +1 -1
- package/prompts/read-guidelines.md +1 -0
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +3 -4
- package/src/anchor-registry.ts +22 -14
- package/src/boundary-bypass.ts +6 -0
- package/src/config-ui.ts +93 -0
- package/src/config.ts +35 -1
- package/src/edit-common.ts +113 -1
- package/src/fs-write.ts +6 -0
- package/src/grep.ts +83 -0
- package/src/hash-store.ts +2 -1
- package/src/hashline/alphabet.ts +1 -1
- package/src/hashline/resolve.ts +5 -4
- package/src/insert.ts +78 -35
- package/src/payload-contract.ts +22 -2
- package/src/read.ts +10 -11
- package/src/replace-render.ts +2 -2
- package/src/replace-response.ts +3 -1
- package/src/replace.ts +35 -18
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Replace a line by its anchor:
|
|
|
40
40
|
|
|
41
41
|
The result is the post-edit diff with fresh anchors, so you can keep editing without re-reading. Lines you did not touch keep their anchors. After a `write`, an auto-read block gives you the new anchors. The most recent `replace` or `insert` on a file can be reverted, even after a restart.
|
|
42
42
|
|
|
43
|
-
The extension registers five tools: `read`, `replace`, `insert`, `anchor_grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `replace` and `insert` take no `path` parameter: the file is resolved from the anchors' session ownership alone, so an edit can only land on the file the anchors were served for.
|
|
43
|
+
The extension registers five tools: `read`, `replace`, `insert`, `anchor_grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `replace` and `insert` take no `path` parameter by default: the file is resolved from the anchors' session ownership alone, so an edit can only land on the file the anchors were served for. Opt in with `/hashline-config` to require `path` in `replace` and `insert` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
|
|
44
44
|
|
|
45
45
|
### read
|
|
46
46
|
|
|
@@ -89,7 +89,7 @@ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_fro
|
|
|
89
89
|
|
|
90
90
|
The request is checked before any file I/O, so a bad request never touches the file.
|
|
91
91
|
|
|
92
|
-
Common copy-paste slips are fixed automatically and reported as warnings: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (
|
|
92
|
+
Common copy-paste slips are fixed automatically and reported as warnings: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 characters before `│`, for example `ab12│`), diff-preview rows pasted into the replacement, a reversed range, and a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped when that block is unique in the file. The whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it.
|
|
93
93
|
|
|
94
94
|
Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown — for example beyond an auto-read preview's truncation cap — is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
|
|
95
95
|
|
|
@@ -115,7 +115,7 @@ The same safety machinery as `replace` applies: undo is saved before the write (
|
|
|
115
115
|
|
|
116
116
|
### anchor_grep
|
|
117
117
|
|
|
118
|
-
`anchor_grep` is an anchored search backed by ripgrep. It is
|
|
118
|
+
`anchor_grep` is an anchored search backed by ripgrep. It is enabled by default; disable it in `/hashline-config` (or set `anchorGrepEnabled` to `false` in the config file). While it is enabled, the built-in grep is disabled. Disabling it removes the tool and restores the built-in grep only if that was active before the extension loaded.
|
|
119
119
|
|
|
120
120
|
Every matching line, and each requested context line, is returned as `lineNumber │ anchor│content`. The `anchor│content` part is served exactly like `read` output, so you can target it with `replace` or `insert` without a separate `read`; the line-number gutter and `=== path ===` header give filename and line for navigation.
|
|
121
121
|
|
|
@@ -153,7 +153,7 @@ Auto-read is enabled by default. After a successful `write`, the extension reads
|
|
|
153
153
|
|
|
154
154
|
After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
155
155
|
|
|
156
|
-
Auto-read keeps the same 50KB and 2000-line budget as `read`.
|
|
156
|
+
Auto-read keeps the same 50KB and 2000-line budget as `read`. Change it in `/hashline-config`; both settings persist across sessions.
|
|
157
157
|
|
|
158
158
|
## Tool result details
|
|
159
159
|
|
|
@@ -170,16 +170,18 @@ All five tools return machine-readable metadata in `details` alongside the model
|
|
|
170
170
|
|
|
171
171
|
| Command | Description |
|
|
172
172
|
| --- | --- |
|
|
173
|
-
| `/
|
|
174
|
-
| `/toggle-anchor-grep` | Enable or disable the `anchor_grep` tool. The built-in grep is disabled while `anchor_grep` is on. Persists across sessions. |
|
|
173
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
|
|
175
174
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
176
175
|
|
|
177
|
-
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first
|
|
176
|
+
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
178
177
|
|
|
179
178
|
```json
|
|
180
179
|
{
|
|
181
180
|
"autoRead": true,
|
|
182
|
-
"anchorGrepEnabled":
|
|
181
|
+
"anchorGrepEnabled": true,
|
|
182
|
+
"requirePath": false,
|
|
183
|
+
"strictInput": false,
|
|
184
|
+
"boundaryDedupEnabled": true
|
|
183
185
|
}
|
|
184
186
|
```
|
|
185
187
|
|
package/index.ts
CHANGED
|
@@ -7,17 +7,23 @@ import { regGrep } from "./src/grep";
|
|
|
7
7
|
import { regUndo, clearUndo } from "./src/replace-undo";
|
|
8
8
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
9
9
|
import type { RMetrics } from "./src/replace-response";
|
|
10
|
+
import type { ReplaceDetails } from "./src/replace";
|
|
10
11
|
import { extractWarnings } from "./src/replace-render";
|
|
11
12
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
12
13
|
import {
|
|
13
14
|
readConfig,
|
|
14
15
|
toggleAutoRead,
|
|
15
16
|
toggleAnchorGrep,
|
|
17
|
+
toggleRequirePath,
|
|
18
|
+
toggleStrictInput,
|
|
19
|
+
toggleBoundaryDedup,
|
|
16
20
|
} from "./src/config";
|
|
17
21
|
import { loadHashStore, persistSnapshot, pruneMissing } from "./src/hash-store";
|
|
18
22
|
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, markServed as markServedScoped } from "./src/anchor-registry";
|
|
19
23
|
import { buildServedMap } from "./src/served";
|
|
20
24
|
import { clearBoundaryBypass } from "./src/boundary-bypass";
|
|
25
|
+
import { currentEditFlags } from "./src/edit-common";
|
|
26
|
+
import { HashlineConfigOverlay } from "./src/config-ui";
|
|
21
27
|
import { registerWriteHook } from "./src/write-hook";
|
|
22
28
|
import { readNormFile } from "./src/file-reader";
|
|
23
29
|
import { loadFileKindAndText } from "./src/file-kind";
|
|
@@ -37,6 +43,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
37
43
|
let autoRead = true;
|
|
38
44
|
let grepWasActive = false;
|
|
39
45
|
|
|
46
|
+
async function refreshEditTools(): Promise<void> {
|
|
47
|
+
try {
|
|
48
|
+
const flags = await currentEditFlags();
|
|
49
|
+
regRead(pi, flags);
|
|
50
|
+
regReplace(pi, flags);
|
|
51
|
+
regInsert(pi, flags);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error("Failed to refresh edit tools:", error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
pi.on("session_start", async (_event, ctx) => {
|
|
41
58
|
const active = pi.getActiveTools();
|
|
42
59
|
grepWasActive = active.includes("grep");
|
|
@@ -56,6 +73,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
56
73
|
await gcRegistrySidecars();
|
|
57
74
|
const config = await readConfig();
|
|
58
75
|
autoRead = config.autoRead;
|
|
76
|
+
await refreshEditTools();
|
|
59
77
|
pi.setActiveTools(
|
|
60
78
|
pi.getActiveTools().filter((t) =>
|
|
61
79
|
config.anchorGrepEnabled ? t !== "grep" : t !== "anchor_grep",
|
|
@@ -67,31 +85,40 @@ export default function (pi: ExtensionAPI): void {
|
|
|
67
85
|
}
|
|
68
86
|
});
|
|
69
87
|
|
|
70
|
-
pi.registerCommand("
|
|
71
|
-
description: "
|
|
88
|
+
pi.registerCommand("hashline-config", {
|
|
89
|
+
description: "Open the hashline settings window (auto-read, grep, path, strict input, dedup)",
|
|
72
90
|
handler: async (_args, ctx) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
if (!ctx.hasUI) {
|
|
92
|
+
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
await ctx.ui.custom<void>(async (tui, theme, _keybindings, done) => {
|
|
96
|
+
const overlay = new HashlineConfigOverlay({
|
|
97
|
+
tui,
|
|
98
|
+
theme,
|
|
99
|
+
done,
|
|
100
|
+
onToggle: async (key) => {
|
|
101
|
+
if (key === "autoRead") autoRead = await toggleAutoRead();
|
|
102
|
+
else if (key === "anchorGrepEnabled") {
|
|
103
|
+
const enabled = await toggleAnchorGrep();
|
|
104
|
+
const active = pi.getActiveTools();
|
|
105
|
+
pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
|
|
106
|
+
}
|
|
107
|
+
else if (key === "requirePath") await toggleRequirePath();
|
|
108
|
+
else if (key === "strictInput") await toggleStrictInput();
|
|
109
|
+
else await toggleBoundaryDedup();
|
|
110
|
+
await refreshEditTools();
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
await overlay.load();
|
|
114
|
+
return overlay;
|
|
115
|
+
}, {
|
|
116
|
+
overlay: true,
|
|
117
|
+
overlayOptions: { anchor: "center", width: "90%", minWidth: 60, maxHeight: "90%" },
|
|
118
|
+
});
|
|
91
119
|
},
|
|
92
120
|
});
|
|
93
121
|
|
|
94
|
-
|
|
95
122
|
pi.registerCommand("clear-anchors", {
|
|
96
123
|
description: "Clear the session's anchor claims (path-free resolution state); anchors are re-claimed on the next read",
|
|
97
124
|
handler: async (_args, ctx) => {
|
|
@@ -164,7 +191,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
164
191
|
const metrics = (event.details as { metrics?: RMetrics } | undefined)?.metrics;
|
|
165
192
|
if (metrics?.classification === "noop") return;
|
|
166
193
|
|
|
167
|
-
const
|
|
194
|
+
const toolDetails = event.details as ReplaceDetails | undefined;
|
|
195
|
+
const diff = toolDetails?.diff;
|
|
196
|
+
const detailWarnings = Array.isArray(toolDetails?.warnings) ? toolDetails.warnings.filter((w): w is string => typeof w === "string") : [];
|
|
168
197
|
if (typeof diff !== "string") return;
|
|
169
198
|
const hasDiff = diff.length > 0;
|
|
170
199
|
|
|
@@ -175,7 +204,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
175
204
|
)
|
|
176
205
|
.map((entry) => entry.text)
|
|
177
206
|
.join("\n");
|
|
178
|
-
const warnings = extractWarnings(rendered);
|
|
207
|
+
const warnings = detailWarnings.length ? `Warnings:\n${detailWarnings.join("\n")}` : extractWarnings(rendered);
|
|
179
208
|
const hint = hasDiff ? (warnings ? `${diff}\n\n${warnings}` : diff) : warnings ? `[post-edit] applied successfully; the diff is empty (whitespace-only change).\n\n${warnings}` : "[post-edit] applied successfully; the diff is empty (whitespace-only change).";
|
|
180
209
|
return {
|
|
181
210
|
content: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row — insert `after` it.
|
|
1
|
+
- `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row — insert `after` it.
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
- `read`: view files with `read`, not `bash` (`sed`/`grep`/`cat`) — only `read` rows carry usable anchors.
|
|
1
2
|
- `read`: call before `replace` when you need fresh anchors.
|
|
2
3
|
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
|
package/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file and return it as `anchor│content` rows, one per line: a 4-character alphanumeric anchor, the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images
|
|
1
|
+
Read a text file and return it as `anchor│content` rows, one per line: a 4-character alphanumeric anchor, the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
- `replace`:
|
|
2
|
-
- `replace`: `replacement_lines` takes bare lines without `│`; `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically.
|
|
3
|
-
- `replace`: keep the range tight — only lines that actually change — and copy leading spaces exactly.
|
|
1
|
+
- `replace`: edit with `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
|
|
2
|
+
- `replace`: `replacement_lines` takes bare lines without `│`; `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically (single line: same anchor for `remove_from` and `remove_to`).
|
|
4
3
|
- `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed. One edit per turn; check the diff before the next edit on that file.
|
|
5
|
-
- `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically, shown as `dedup│content` rows in the diff (not editable, never use `dedup` as an anchor).
|
|
4
|
+
- `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically, shown as `dedup│content` rows in the diff (not editable, never use `dedup` as an anchor).
|
package/src/anchor-registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { chmod, mkdir, readFile, readdir, rm, stat } from "fs/promises";
|
|
2
|
+
import { appendFileSync, chmodSync } from "fs";
|
|
2
3
|
import { join } from "path";
|
|
3
4
|
import { createHash } from "crypto";
|
|
4
5
|
import { sessionClaimsDir } from "./paths";
|
|
@@ -129,8 +130,12 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
|
|
|
129
130
|
seedServedFromOwned(folded);
|
|
130
131
|
registries.set(key, folded);
|
|
131
132
|
try {
|
|
132
|
-
await mkdir(sessionClaimsDir(), { recursive: true });
|
|
133
|
-
|
|
133
|
+
await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
|
|
134
|
+
if (process.platform !== "win32") {
|
|
135
|
+
try { await chmod(sessionClaimsDir(), 0o700); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry directory:", error); }
|
|
136
|
+
try { await chmod(currentSidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
|
|
137
|
+
}
|
|
138
|
+
appendEvent({ kind: "session", sessionFile } satisfies RegistryEvent);
|
|
134
139
|
} catch (error) {
|
|
135
140
|
console.error("Failed to initialize anchor registry sidecar:", error);
|
|
136
141
|
}
|
|
@@ -142,9 +147,15 @@ function current(): SessionState | undefined {
|
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
function appendEvent(event: RegistryEvent): void {
|
|
145
|
-
|
|
150
|
+
if (!currentSidecar) return;
|
|
151
|
+
try {
|
|
152
|
+
appendFileSync(currentSidecar, JSON.stringify(event) + "\n", "utf-8");
|
|
153
|
+
if (process.platform !== "win32") {
|
|
154
|
+
try { chmodSync(currentSidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
146
157
|
console.error("Failed to append registry event:", error);
|
|
147
|
-
}
|
|
158
|
+
}
|
|
148
159
|
}
|
|
149
160
|
|
|
150
161
|
function fingerprintIndex(state: SessionState, path: string): Map<string, string[]> {
|
|
@@ -188,9 +199,7 @@ export function allocateAnchor(path: string, checksum: string): string {
|
|
|
188
199
|
const anchor = mintAnchor(state);
|
|
189
200
|
state.everMinted.add(anchor);
|
|
190
201
|
state.owned.set(anchor, { path, checksum });
|
|
191
|
-
|
|
192
|
-
appendEvent({ kind: "allocate", path, rows: [[anchor, checksum]] });
|
|
193
|
-
}
|
|
202
|
+
appendEvent({ kind: "allocate", path, rows: [[anchor, checksum]] });
|
|
194
203
|
return anchor;
|
|
195
204
|
}
|
|
196
205
|
|
|
@@ -217,7 +226,7 @@ export function freeAnchors(path: string, anchors?: string[]): void {
|
|
|
217
226
|
}
|
|
218
227
|
state.served.delete(path);
|
|
219
228
|
}
|
|
220
|
-
if (freed.length > 0
|
|
229
|
+
if (freed.length > 0) {
|
|
221
230
|
appendEvent({ kind: "free", path, anchors: anchors ?? undefined });
|
|
222
231
|
}
|
|
223
232
|
}
|
|
@@ -227,7 +236,6 @@ export function clearRegistry(): void {
|
|
|
227
236
|
if (!state) return;
|
|
228
237
|
state.owned.clear();
|
|
229
238
|
state.served.clear();
|
|
230
|
-
if (!currentSidecar) return;
|
|
231
239
|
appendEvent({ kind: "clear" });
|
|
232
240
|
}
|
|
233
241
|
|
|
@@ -362,7 +370,7 @@ export function alignOwnershipWithSpans(
|
|
|
362
370
|
const freed: { anchor: string; checksum: string }[] = [];
|
|
363
371
|
const minted: MintedAt[] = [];
|
|
364
372
|
const log = (event: RegistryEvent): void => {
|
|
365
|
-
if (!options?.shadow
|
|
373
|
+
if (!options?.shadow) appendEvent(event);
|
|
366
374
|
};
|
|
367
375
|
|
|
368
376
|
const sorted = [...spans].sort((a, b) => a.start - b.start);
|
|
@@ -432,7 +440,7 @@ export function alignOwnership(
|
|
|
432
440
|
const freed: { anchor: string; checksum: string }[] = [];
|
|
433
441
|
const minted: MintedAt[] = [];
|
|
434
442
|
const log = (event: RegistryEvent): void => {
|
|
435
|
-
if (!options?.shadow
|
|
443
|
+
if (!options?.shadow) appendEvent(event);
|
|
436
444
|
};
|
|
437
445
|
if (prevAnchors) {
|
|
438
446
|
for (const anchor of prevAnchors) state.everMinted.add(anchor);
|
|
@@ -544,7 +552,7 @@ export async function allocateFileAnchors(
|
|
|
544
552
|
state.owned.set(anchor, { path, checksum });
|
|
545
553
|
return anchor;
|
|
546
554
|
});
|
|
547
|
-
if (!shadow
|
|
555
|
+
if (!shadow) {
|
|
548
556
|
appendEvent({ kind: "allocate", path, rows: anchors.map((a, i) => [a, checksums[i]!]) });
|
|
549
557
|
}
|
|
550
558
|
return { anchors, freed: [], minted: anchors };
|
|
@@ -567,7 +575,7 @@ export function adoptAnchors(path: string, entries: Map<string, string>): void {
|
|
|
567
575
|
state.owned.set(anchor, { path, checksum });
|
|
568
576
|
served.set(anchor, checksum);
|
|
569
577
|
}
|
|
570
|
-
if (
|
|
578
|
+
if (entries.size > 0) {
|
|
571
579
|
appendEvent({ kind: "allocate", path, rows: [...entries] });
|
|
572
580
|
}
|
|
573
581
|
}
|
package/src/boundary-bypass.ts
CHANGED
|
@@ -13,6 +13,7 @@ function canonLines(lines: string[]): string[] {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const boundaryBypassTracker = new Map<string, string>();
|
|
16
|
+
const BOUNDARY_BYPASS_LIMIT = 256;
|
|
16
17
|
|
|
17
18
|
export function noopPayloadKey(
|
|
18
19
|
absolutePath: string,
|
|
@@ -29,7 +30,12 @@ export function noopPayloadKey(
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function markBoundaryNoop(absolutePath: string, payload: string): void {
|
|
33
|
+
boundaryBypassTracker.delete(absolutePath);
|
|
32
34
|
boundaryBypassTracker.set(absolutePath, payload);
|
|
35
|
+
if (boundaryBypassTracker.size > BOUNDARY_BYPASS_LIMIT) {
|
|
36
|
+
const oldest = boundaryBypassTracker.keys().next().value;
|
|
37
|
+
if (oldest !== undefined) boundaryBypassTracker.delete(oldest);
|
|
38
|
+
}
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export function consumeBoundaryBypass(absolutePath: string, payload: string): boolean {
|
package/src/config-ui.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { readConfig, type Config } from "./config";
|
|
4
|
+
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupEnabled";
|
|
6
|
+
|
|
7
|
+
export interface ConfigRow {
|
|
8
|
+
key: ConfigToggleKey;
|
|
9
|
+
label: string;
|
|
10
|
+
hint: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function configRows(config: Config): ConfigRow[] {
|
|
15
|
+
return [
|
|
16
|
+
{ key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
|
|
17
|
+
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
18
|
+
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|
|
19
|
+
{ key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
20
|
+
{ key: "boundaryDedupEnabled", label: "Boundary dedup", hint: "Strip re-included edge lines (off = literal)", enabled: config.boundaryDedupEnabled !== false },
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function padRow(theme: Theme, innerWidth: number, content: string): string {
|
|
25
|
+
const padded = content + " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
|
|
26
|
+
return theme.fg("border", "│") + padded + theme.fg("border", "│");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class HashlineConfigOverlay {
|
|
30
|
+
private rows: ConfigRow[];
|
|
31
|
+
private selected = 0;
|
|
32
|
+
|
|
33
|
+
constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey) => Promise<void> }) {
|
|
34
|
+
this.rows = [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async load(): Promise<void> {
|
|
38
|
+
this.rows = configRows(await readConfig());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private toggleSelected(): void {
|
|
42
|
+
const row = this.rows[this.selected];
|
|
43
|
+
if (!row) return;
|
|
44
|
+
row.enabled = !row.enabled;
|
|
45
|
+
this.opts.tui.requestRender(true);
|
|
46
|
+
void this.opts.onToggle(row.key).then(async () => {
|
|
47
|
+
this.rows = configRows(await readConfig());
|
|
48
|
+
this.opts.tui.requestRender(true);
|
|
49
|
+
}).catch((error: unknown) => {
|
|
50
|
+
console.error("Failed to toggle hashline setting:", error);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
handleInput(data: string): void {
|
|
55
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
56
|
+
this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
60
|
+
this.selected = (this.selected + 1) % this.rows.length;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (matchesKey(data, Key.space) || matchesKey(data, Key.enter) || data === " " || data === "\r" || data === "\n") {
|
|
64
|
+
this.toggleSelected();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
68
|
+
this.opts.done();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidate(): void {
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
render(width: number): string[] {
|
|
76
|
+
const theme = this.opts.theme;
|
|
77
|
+
const innerWidth = width - 2;
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
lines.push(theme.fg("border", `╭${"─".repeat(innerWidth)}╮`));
|
|
80
|
+
lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", theme.bold("Hashline Config"))}`));
|
|
81
|
+
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
82
|
+
this.rows.forEach((row, index) => {
|
|
83
|
+
const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
|
|
84
|
+
const box = row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
|
85
|
+
const label = index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
|
|
86
|
+
lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", `— ${row.hint}`)}`));
|
|
87
|
+
});
|
|
88
|
+
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
89
|
+
lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · q close")));
|
|
90
|
+
lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -6,11 +6,17 @@ import { writeAtomic } from "./fs-write";
|
|
|
6
6
|
export interface Config {
|
|
7
7
|
autoRead: boolean;
|
|
8
8
|
anchorGrepEnabled: boolean;
|
|
9
|
+
requirePath?: boolean;
|
|
10
|
+
strictInput?: boolean;
|
|
11
|
+
boundaryDedupEnabled?: boolean;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
const DEFAULT_CONFIG: Config = {
|
|
12
15
|
autoRead: true,
|
|
13
|
-
anchorGrepEnabled:
|
|
16
|
+
anchorGrepEnabled: true,
|
|
17
|
+
requirePath: false,
|
|
18
|
+
strictInput: false,
|
|
19
|
+
boundaryDedupEnabled: true
|
|
14
20
|
};
|
|
15
21
|
|
|
16
22
|
function parseConfig(content: string): Config {
|
|
@@ -20,9 +26,15 @@ function parseConfig(content: string): Config {
|
|
|
20
26
|
throw new Error("config.json must be an object with a boolean autoRead field");
|
|
21
27
|
}
|
|
22
28
|
const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
|
|
29
|
+
const requirePath = isRec(parsed) ? parsed.requirePath : undefined;
|
|
30
|
+
const strictInput = isRec(parsed) ? parsed.strictInput : undefined;
|
|
31
|
+
const boundaryDedupEnabled = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
|
|
23
32
|
return {
|
|
24
33
|
autoRead,
|
|
25
34
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
35
|
+
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
36
|
+
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
37
|
+
boundaryDedupEnabled: typeof boundaryDedupEnabled === "boolean" ? boundaryDedupEnabled : DEFAULT_CONFIG.boundaryDedupEnabled,
|
|
26
38
|
};
|
|
27
39
|
}
|
|
28
40
|
|
|
@@ -56,3 +68,25 @@ export async function toggleAnchorGrep(): Promise<boolean> {
|
|
|
56
68
|
await writeConfig(config);
|
|
57
69
|
return config.anchorGrepEnabled;
|
|
58
70
|
}
|
|
71
|
+
|
|
72
|
+
export async function toggleRequirePath(): Promise<boolean> {
|
|
73
|
+
const config = await readConfig();
|
|
74
|
+
config.requirePath = !config.requirePath;
|
|
75
|
+
await writeConfig(config);
|
|
76
|
+
return config.requirePath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function toggleStrictInput(): Promise<boolean> {
|
|
80
|
+
const config = await readConfig();
|
|
81
|
+
config.strictInput = !(config.strictInput === true);
|
|
82
|
+
await writeConfig(config);
|
|
83
|
+
return config.strictInput === true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function toggleBoundaryDedup(): Promise<boolean> {
|
|
87
|
+
const config = await readConfig();
|
|
88
|
+
const enabled = config.boundaryDedupEnabled !== false;
|
|
89
|
+
config.boundaryDedupEnabled = !enabled;
|
|
90
|
+
await writeConfig(config);
|
|
91
|
+
return !enabled;
|
|
92
|
+
}
|
package/src/edit-common.ts
CHANGED
|
@@ -3,11 +3,81 @@ import { resolveInCwd } from "./fs-write";
|
|
|
3
3
|
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
4
|
import { ownerOf } from "./anchor-registry";
|
|
5
5
|
import { parseHashRef, stripAnchorRow } from "./hashline";
|
|
6
|
+
import { readConfig } from "./config";
|
|
6
7
|
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
7
8
|
import type { ReplaceDetails } from "./replace";
|
|
8
|
-
|
|
9
9
|
export const editPrepare = makePrepareArguments();
|
|
10
10
|
|
|
11
|
+
export interface EditToolFlags {
|
|
12
|
+
requirePath: boolean;
|
|
13
|
+
strictInput: boolean;
|
|
14
|
+
boundaryDedupEnabled: boolean;
|
|
15
|
+
autoRead: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
|
|
19
|
+
requirePath: false,
|
|
20
|
+
strictInput: false,
|
|
21
|
+
boundaryDedupEnabled: true,
|
|
22
|
+
autoRead: true
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function currentEditFlags(): Promise<EditToolFlags> {
|
|
26
|
+
const config = await readConfig();
|
|
27
|
+
return {
|
|
28
|
+
requirePath: config.requirePath === true,
|
|
29
|
+
strictInput: config.strictInput === true,
|
|
30
|
+
boundaryDedupEnabled: config.boundaryDedupEnabled !== false,
|
|
31
|
+
autoRead: config.autoRead !== false
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function withReplacePrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
36
|
+
let description = base.description;
|
|
37
|
+
const snippetParts = [base.snippet];
|
|
38
|
+
let guidelines = [...base.guidelines];
|
|
39
|
+
if (!flags.autoRead) {
|
|
40
|
+
description = description.replace(" Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading.", "");
|
|
41
|
+
guidelines = guidelines.map((guideline) => guideline.includes("post-edit diff") ? "`replace`: one edit per turn; verify each result before the next edit on that file." : guideline);
|
|
42
|
+
}
|
|
43
|
+
const descriptionParts = [description];
|
|
44
|
+
if (flags.requirePath) {
|
|
45
|
+
descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
|
|
46
|
+
snippetParts.push("; include `path` (required)");
|
|
47
|
+
guidelines.push("`replace`: include `path` matching the file the anchors were served for; it is required.");
|
|
48
|
+
}
|
|
49
|
+
if (flags.strictInput) {
|
|
50
|
+
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
51
|
+
guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
|
|
52
|
+
}
|
|
53
|
+
if (!flags.boundaryDedupEnabled) {
|
|
54
|
+
descriptionParts.push("Boundary dedup is off: edits apply literally.");
|
|
55
|
+
guidelines.push("`replace`: boundary dedup is off: edits apply literally.");
|
|
56
|
+
}
|
|
57
|
+
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
61
|
+
if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...base.guidelines] };
|
|
62
|
+
const guidelines = base.guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
|
|
63
|
+
return { description: base.description, snippet: base.snippet, guidelines };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function withInsertPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
67
|
+
const descriptionParts = [base.description];
|
|
68
|
+
const snippetParts = [base.snippet];
|
|
69
|
+
const guidelines = [...base.guidelines];
|
|
70
|
+
if (flags.requirePath) {
|
|
71
|
+
descriptionParts.push("Also give `path` matching the file the anchor was served for; it is required and must match anchor ownership.");
|
|
72
|
+
snippetParts.push("; include `path` (required)");
|
|
73
|
+
guidelines.push("`insert`: include `path` matching the file the anchor was served for; it is required.");
|
|
74
|
+
}
|
|
75
|
+
if (flags.strictInput) {
|
|
76
|
+
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
77
|
+
guidelines.push("`insert`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
|
|
78
|
+
}
|
|
79
|
+
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
80
|
+
}
|
|
11
81
|
export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
|
|
12
82
|
const refs = [removeFrom, removeTo].filter((value): value is string => typeof value === "string");
|
|
13
83
|
const owners = refs.map((ref) => ownerOf(parseHashRef(stripAnchorRow(ref.trim(), "anchor entry")).hash));
|
|
@@ -35,6 +105,48 @@ export function tryResolveEditTarget(removeFrom: string | undefined, removeTo?:
|
|
|
35
105
|
}
|
|
36
106
|
}
|
|
37
107
|
|
|
108
|
+
export interface PathRequirementInput {
|
|
109
|
+
removeFrom?: string;
|
|
110
|
+
removeTo?: string;
|
|
111
|
+
anchor?: string;
|
|
112
|
+
providedPath?: unknown;
|
|
113
|
+
cwd: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
|
|
117
|
+
const { requirePath } = await readConfig();
|
|
118
|
+
if (!requirePath && input.providedPath !== undefined) {
|
|
119
|
+
throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; enable require-path in /hashline-config to require it.");
|
|
120
|
+
}
|
|
121
|
+
if (requirePath && (typeof input.providedPath !== "string" || input.providedPath.length === 0)) {
|
|
122
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on (disable it in /hashline-config).');
|
|
123
|
+
}
|
|
124
|
+
const anchorTarget = typeof input.anchor === "string"
|
|
125
|
+
? resolveEditTarget(input.anchor)
|
|
126
|
+
: resolveEditTarget(input.removeFrom as string, input.removeTo);
|
|
127
|
+
if (requirePath) {
|
|
128
|
+
const { resolved } = await resolveInCwd(input.providedPath as string, input.cwd);
|
|
129
|
+
if (resolved !== anchorTarget) {
|
|
130
|
+
throw new Error(`[E_BAD_SHAPE] Provided "path" "${input.providedPath}" does not match anchor ownership "${anchorTarget}".`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return anchorTarget;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function throwIfStrictInput(warnings: string[]): Promise<void> {
|
|
137
|
+
const fixes = warnings.filter((warning) => warning.startsWith("[W_"));
|
|
138
|
+
if (fixes.length === 0) return;
|
|
139
|
+
const { strictInput } = await readConfig();
|
|
140
|
+
if (strictInput === true) {
|
|
141
|
+
throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input (disable it in /hashline-config):\n${fixes.join("\n")}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function isBoundaryDedupEnabled(): Promise<boolean> {
|
|
146
|
+
const { boundaryDedupEnabled } = await readConfig();
|
|
147
|
+
return boundaryDedupEnabled !== false;
|
|
148
|
+
}
|
|
149
|
+
|
|
38
150
|
export function editRenderCallWrapper(
|
|
39
151
|
preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
|
|
40
152
|
getInput?: (args: unknown) => { path?: string } | null,
|
package/src/fs-write.ts
CHANGED
|
@@ -101,12 +101,18 @@ const TEMP_PREFIX = ".tmp-";
|
|
|
101
101
|
const TEMP_UUID_RE = /^\.tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
102
102
|
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
103
103
|
const sweptDirs = new Map<string, number>();
|
|
104
|
+
const SWEPT_DIRS_LIMIT = 256;
|
|
104
105
|
|
|
105
106
|
async function sweepStaleTemps(dir: string): Promise<void> {
|
|
106
107
|
const sweepNow = Date.now();
|
|
107
108
|
const lastSweep = sweptDirs.get(dir);
|
|
108
109
|
if (lastSweep !== undefined && sweepNow - lastSweep < STALE_TEMP_MS) return;
|
|
110
|
+
sweptDirs.delete(dir);
|
|
109
111
|
sweptDirs.set(dir, sweepNow);
|
|
112
|
+
if (sweptDirs.size > SWEPT_DIRS_LIMIT) {
|
|
113
|
+
const oldest = sweptDirs.keys().next().value;
|
|
114
|
+
if (oldest !== undefined) sweptDirs.delete(oldest);
|
|
115
|
+
}
|
|
110
116
|
try {
|
|
111
117
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
112
118
|
for (const entry of entries) {
|
package/src/grep.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { normReq } from "./payload-contract";
|
|
|
14
14
|
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
15
15
|
import { markServed as markServedScoped } from "./anchor-registry";
|
|
16
16
|
import { buildServedMap } from "./served";
|
|
17
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
18
|
+
import { expandHint, getResultText, reuseText, type CallT, type FgT } from "./replace-render";
|
|
17
19
|
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
18
20
|
|
|
19
21
|
function cmp(a: string, b: string): number {
|
|
@@ -452,6 +454,79 @@ const grepToolSchema = Type.Object(
|
|
|
452
454
|
{ additionalProperties: false },
|
|
453
455
|
);
|
|
454
456
|
|
|
457
|
+
|
|
458
|
+
const GREP_PREVIEW_LINES = 16;
|
|
459
|
+
const GREP_PREVIEW_LINES_EXPANDED = 40;
|
|
460
|
+
|
|
461
|
+
export function fmtGrepCall(args: { pattern?: unknown; path?: unknown; glob?: unknown; literal?: unknown; ignoreCase?: unknown; context?: unknown; limit?: unknown } | undefined, theme: CallT): string {
|
|
462
|
+
const pattern = typeof args?.pattern === "string" && args.pattern.length > 0 ? theme.fg("accent", args.pattern) : theme.fg("toolOutput", "...");
|
|
463
|
+
const qualifiers: string[] = [];
|
|
464
|
+
if (typeof args?.path === "string" && args.path.length > 0) qualifiers.push(args.path);
|
|
465
|
+
if (typeof args?.glob === "string" && args.glob.length > 0) qualifiers.push(args.glob);
|
|
466
|
+
if (args?.literal === true) qualifiers.push("literal");
|
|
467
|
+
if (args?.ignoreCase === true) qualifiers.push("case-insensitive");
|
|
468
|
+
if (typeof args?.context === "number") qualifiers.push(`context ${args.context}`);
|
|
469
|
+
if (typeof args?.limit === "number") qualifiers.push(`limit ${args.limit}`);
|
|
470
|
+
let text = `${theme.fg("toolTitle", theme.bold("anchor_grep"))} ${pattern}`;
|
|
471
|
+
if (qualifiers.length > 0) text += ` ${theme.fg("dim", qualifiers.join(" "))}`;
|
|
472
|
+
return text;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function highlightRegex(args: unknown): RegExp | undefined {
|
|
476
|
+
if (!isRec(args) || typeof args.pattern !== "string" || args.pattern.length === 0) return undefined;
|
|
477
|
+
try {
|
|
478
|
+
const validated = buildRegex(args.pattern, args.literal === true, args.ignoreCase === true);
|
|
479
|
+
return new RegExp(validated.source, validated.flags.includes("i") ? "giu" : "gu");
|
|
480
|
+
} catch {
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function highlightMatches(text: string, regex: RegExp, theme: FgT): string {
|
|
486
|
+
let out = "";
|
|
487
|
+
let last = 0;
|
|
488
|
+
regex.lastIndex = 0;
|
|
489
|
+
for (;;) {
|
|
490
|
+
const match = regex.exec(text);
|
|
491
|
+
if (!match || match[0].length === 0) break;
|
|
492
|
+
out += text.slice(last, match.index) + theme.fg("accent", match[0]);
|
|
493
|
+
last = match.index + match[0].length;
|
|
494
|
+
}
|
|
495
|
+
return out + text.slice(last);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const ANCHORED_ROW_RE = /^[A-Za-z0-9]{4}│/;
|
|
499
|
+
|
|
500
|
+
function highlightHitRow(row: string, highlight: RegExp, theme: FgT): string {
|
|
501
|
+
const anchored = row.match(ANCHORED_ROW_RE);
|
|
502
|
+
if (!anchored) return highlightMatches(row, highlight, theme);
|
|
503
|
+
return anchored[0] + highlightMatches(row.slice(anchored[0].length), highlight, theme);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function styleGrepLine(line: string, highlight: RegExp | undefined, theme: FgT): string {
|
|
507
|
+
if (line.startsWith("=== ")) return theme.fg("accent", line);
|
|
508
|
+
if (line.startsWith("[grep:") || line.startsWith("... ")) return theme.fg("dim", line);
|
|
509
|
+
if (!highlight) return line;
|
|
510
|
+
const gutter = line.indexOf(" │ ");
|
|
511
|
+
if (gutter < 0) return highlightHitRow(line, highlight, theme);
|
|
512
|
+
const cut = gutter + " │ ".length;
|
|
513
|
+
return line.slice(0, cut) + highlightHitRow(line.slice(cut), highlight, theme);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function renderGrepResult(result: { content?: Array<{ type: string; text?: string }> }, options: { isPartial: boolean; expanded?: boolean } | boolean, theme: FgT, context: any): Text {
|
|
517
|
+
const isPartial = typeof options === "boolean" ? options : options.isPartial;
|
|
518
|
+
const expanded = typeof options === "boolean" ? context.expanded === true : options.expanded === true || context.expanded === true;
|
|
519
|
+
if (isPartial) return reuseText(context, theme.fg("warning", "Searching..."));
|
|
520
|
+
const raw = getResultText(result);
|
|
521
|
+
if (context.isError) return raw ? reuseText(context, `\n${theme.fg("error", raw)}`) : new Text("", 0, 0);
|
|
522
|
+
if (!raw) return new Text("", 0, 0);
|
|
523
|
+
const maxLines = expanded ? GREP_PREVIEW_LINES_EXPANDED : GREP_PREVIEW_LINES;
|
|
524
|
+
const lines = raw.split("\n");
|
|
525
|
+
const highlight = highlightRegex((context as { args?: unknown }).args);
|
|
526
|
+
const shown = lines.slice(0, maxLines).map((line) => styleGrepLine(line, highlight, theme));
|
|
527
|
+
if (lines.length > maxLines) shown.push(theme.fg("muted", `... ${lines.length - maxLines} more grep lines (${expandHint()})`));
|
|
528
|
+
return reuseText(context, shown.join("\n"));
|
|
529
|
+
}
|
|
455
530
|
export function regGrep(pi: ExtensionAPI): void {
|
|
456
531
|
pi.registerTool({
|
|
457
532
|
name: "anchor_grep",
|
|
@@ -462,6 +537,14 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
462
537
|
prepareArguments: makePrepareArguments(),
|
|
463
538
|
parameters: grepToolSchema,
|
|
464
539
|
executionMode: "sequential",
|
|
540
|
+
renderCall(args: any, theme: CallT, context: any) {
|
|
541
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
542
|
+
text.setText(fmtGrepCall(args as { pattern?: unknown; path?: unknown } | undefined, theme));
|
|
543
|
+
return text;
|
|
544
|
+
},
|
|
545
|
+
renderResult(result, opts, theme, context) {
|
|
546
|
+
return renderGrepResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
|
|
547
|
+
},
|
|
465
548
|
|
|
466
549
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
467
550
|
const canonical = normReq(params);
|
package/src/hash-store.ts
CHANGED
|
@@ -535,7 +535,8 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
|
|
|
535
535
|
await stat(row.path);
|
|
536
536
|
return undefined;
|
|
537
537
|
} catch (error: unknown) {
|
|
538
|
-
|
|
538
|
+
const code = errCode(error);
|
|
539
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
539
540
|
console.error("Failed to stat hash store path:", row.path, error);
|
|
540
541
|
return undefined;
|
|
541
542
|
}
|
package/src/hashline/alphabet.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const ALPH_RE = new RegExp(`^[${ALNUM}]+$`);
|
|
|
12
12
|
|
|
13
13
|
export const HASH_CLASS = `[${ALNUM}]{${HASH_LEN}}`;
|
|
14
14
|
|
|
15
|
-
export const HASH_RUN = `[${ALNUM}]{
|
|
15
|
+
export const HASH_RUN = `[${ALNUM}]{${HASH_LEN},${HASH_LEN + 1}}`;
|
|
16
16
|
|
|
17
17
|
export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
|
|
18
18
|
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
|
|
2
|
-
import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
|
|
3
2
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
3
|
+
import { HASH_SEP, stripRowPrefix, canon } from "./hash";
|
|
4
|
+
import { HASH_RUN } from "./alphabet";
|
|
4
5
|
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
6
|
import { contentChecksum } from "./hasher";
|
|
6
7
|
import { hashSource } from "./hash";
|
|
@@ -97,7 +98,7 @@ export function fmtMismatchWithHashes(
|
|
|
97
98
|
if (notFound.length > 0) {
|
|
98
99
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
99
100
|
out.push(
|
|
100
|
-
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read() for fresh anchors.`
|
|
101
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read()${filePath ? ` on ${filePath}` : ""} for fresh anchors.`
|
|
101
102
|
);
|
|
102
103
|
for (const m of notFound) {
|
|
103
104
|
const ctx = m.context;
|
|
@@ -536,8 +537,8 @@ export function assertRangeServed(
|
|
|
536
537
|
: `${mismatchLines.length} of ${rangeLength} line(s) in the replaced range (lines ${startLine}-${endLine})${location} do not match`;
|
|
537
538
|
const capHint =
|
|
538
539
|
rangeLength > shownLength
|
|
539
|
-
? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read() with offset=${startLine + shownLength} to see the rest.]`
|
|
540
|
-
: "";
|
|
540
|
+
? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read()${filePath ? ` on ${filePath}` : ""} with offset=${startLine + shownLength} to see the rest.]`
|
|
541
|
+
: "\n\nRetry with the fresh anchors above without a read.";
|
|
541
542
|
const message =
|
|
542
543
|
`[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
543
544
|
throw new RangeStaleError(message, shownHashes, shownMap);
|
package/src/insert.ts
CHANGED
|
@@ -10,13 +10,13 @@ import { loadP, loadGuide } from "./prompts";
|
|
|
10
10
|
import { normReq } from "./payload-contract";
|
|
11
11
|
import { decodeStringArray, isRec, rejectUnknownFields, splitLines } from "./utils";
|
|
12
12
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
13
|
-
import {
|
|
13
|
+
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
14
14
|
import type { RPreview, RRState } from "./replace-render";
|
|
15
|
-
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
|
|
16
15
|
|
|
17
|
-
const INSERT_KS = new Set(["anchor", "direction", "lines"]);
|
|
16
|
+
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
18
17
|
|
|
19
18
|
export interface InsertReq {
|
|
19
|
+
path?: string;
|
|
20
20
|
anchor: string;
|
|
21
21
|
direction: "before" | "after";
|
|
22
22
|
lines: string[];
|
|
@@ -27,6 +27,9 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
27
27
|
throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
|
|
28
28
|
}
|
|
29
29
|
rejectUnknownFields(request, INSERT_KS, "Insert request");
|
|
30
|
+
if (request.path !== undefined && typeof request.path !== "string") {
|
|
31
|
+
throw new Error('[E_BAD_SHAPE] Insert request field "path" must be a string when provided.');
|
|
32
|
+
}
|
|
30
33
|
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
31
34
|
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
|
|
32
35
|
}
|
|
@@ -38,33 +41,52 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
const insertAnchorSchema = Type.String({
|
|
45
|
+
description:
|
|
46
|
+
'Bare 4-char anchor from a read row (the text before the `│` separator), never the row content. A pasted diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const insertDirectionSchema = Type.Union(
|
|
50
|
+
[Type.Literal("after"), Type.Literal("before")],
|
|
51
|
+
{ description: '"after" or "before"' },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const insertLinesSchema = Type.Array(
|
|
55
|
+
Type.String({
|
|
56
|
+
description: "One line to insert; never embed \\n inside an element.",
|
|
57
|
+
}),
|
|
58
|
+
{
|
|
59
|
+
description: 'One string per line; [""] is a blank line; never include the anchor line.',
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const insertPathRequiredSchema = Type.String({
|
|
64
|
+
description:
|
|
65
|
+
"Path to the file the anchor was served for; required and must match anchor ownership. The anchor still resolves the target.",
|
|
66
|
+
});
|
|
67
|
+
|
|
41
68
|
const insertToolSchema = Type.Object(
|
|
42
69
|
{
|
|
43
|
-
anchor:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}),
|
|
47
|
-
direction: Type.Union(
|
|
48
|
-
[
|
|
49
|
-
Type.Literal("after"),
|
|
50
|
-
Type.Literal("before"),
|
|
51
|
-
],
|
|
52
|
-
{ description: '"after" or "before"' },
|
|
53
|
-
),
|
|
54
|
-
lines: Type.Array(
|
|
55
|
-
Type.String({
|
|
56
|
-
description:
|
|
57
|
-
"One line to insert; never embed \\n inside an element.",
|
|
58
|
-
}),
|
|
59
|
-
{
|
|
60
|
-
description:
|
|
61
|
-
'One string per line; [""] is a blank line; never include the anchor line.',
|
|
62
|
-
}
|
|
63
|
-
),
|
|
70
|
+
anchor: insertAnchorSchema,
|
|
71
|
+
direction: insertDirectionSchema,
|
|
72
|
+
lines: insertLinesSchema,
|
|
64
73
|
},
|
|
65
74
|
{ additionalProperties: false },
|
|
66
75
|
);
|
|
67
76
|
|
|
77
|
+
export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSchema {
|
|
78
|
+
if (!requirePath) return insertToolSchema;
|
|
79
|
+
return Type.Object(
|
|
80
|
+
{
|
|
81
|
+
path: insertPathRequiredSchema,
|
|
82
|
+
anchor: insertAnchorSchema,
|
|
83
|
+
direction: insertDirectionSchema,
|
|
84
|
+
lines: insertLinesSchema,
|
|
85
|
+
},
|
|
86
|
+
{ additionalProperties: false },
|
|
87
|
+
) as typeof insertToolSchema;
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
|
|
69
91
|
const trimmedAnchor = raw.trim();
|
|
70
92
|
const warnings: string[] = [];
|
|
@@ -97,17 +119,28 @@ function buildInsertEdit(
|
|
|
97
119
|
export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
98
120
|
try {
|
|
99
121
|
const normalized = normReq(request);
|
|
122
|
+
const previewFixes: string[] = [];
|
|
100
123
|
if (isRec(normalized)) {
|
|
101
124
|
const expanded = decodeStringArray(normalized.lines);
|
|
102
|
-
if (expanded)
|
|
125
|
+
if (expanded) {
|
|
126
|
+
previewFixes.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
|
|
127
|
+
normalized.lines = expanded;
|
|
128
|
+
}
|
|
103
129
|
}
|
|
104
130
|
assertInsertReq(normalized);
|
|
105
|
-
const
|
|
106
|
-
const
|
|
131
|
+
const previewReq = normalized as InsertReq;
|
|
132
|
+
const { ref, warnings: previewAnchorWarnings } = parseInsertAnchor(previewReq.anchor);
|
|
133
|
+
await throwIfStrictInput([...previewFixes, ...previewAnchorWarnings]);
|
|
134
|
+
const targetPath = await resolveEditTargetWithRequirement({
|
|
135
|
+
anchor: previewReq.anchor,
|
|
136
|
+
providedPath: previewReq.path,
|
|
137
|
+
cwd,
|
|
138
|
+
});
|
|
107
139
|
const preload = await readNormFile(targetPath, cwd, {
|
|
108
140
|
accessMode: constants.R_OK,
|
|
109
141
|
maxLines: MAX_HASH_LINES,
|
|
110
142
|
noPersist: true,
|
|
143
|
+
allocation: "shadow",
|
|
111
144
|
signal,
|
|
112
145
|
});
|
|
113
146
|
const { editParams } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
@@ -151,15 +184,20 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
|
|
|
151
184
|
|
|
152
185
|
type InsertToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
|
|
153
186
|
|
|
154
|
-
export function buildInsertToolDef(): InsertToolDef {
|
|
187
|
+
export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): InsertToolDef {
|
|
188
|
+
const prompted = withInsertPrompts({
|
|
189
|
+
description: loadP("../prompts/insert.md"),
|
|
190
|
+
snippet: loadP("../prompts/insert-snippet.md"),
|
|
191
|
+
guidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
192
|
+
}, flags);
|
|
155
193
|
return {
|
|
156
194
|
name: "insert",
|
|
157
195
|
label: "Insert",
|
|
158
|
-
description:
|
|
159
|
-
promptSnippet:
|
|
160
|
-
promptGuidelines:
|
|
196
|
+
description: prompted.description,
|
|
197
|
+
promptSnippet: prompted.snippet,
|
|
198
|
+
promptGuidelines: prompted.guidelines,
|
|
161
199
|
...editToolBase,
|
|
162
|
-
parameters:
|
|
200
|
+
parameters: buildInsertToolSchema(flags.requirePath),
|
|
163
201
|
renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
|
|
164
202
|
renderResult: editRenderResultWrapper,
|
|
165
203
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
@@ -174,8 +212,13 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
174
212
|
}
|
|
175
213
|
assertInsertReq(canonical);
|
|
176
214
|
const req = canonical;
|
|
177
|
-
const targetPath =
|
|
215
|
+
const targetPath = await resolveEditTargetWithRequirement({
|
|
216
|
+
anchor: req.anchor,
|
|
217
|
+
providedPath: req.path,
|
|
218
|
+
cwd: ctx.cwd,
|
|
219
|
+
});
|
|
178
220
|
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
221
|
+
await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
|
|
179
222
|
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
180
223
|
const preload = await readNormFile(targetPath, ctx.cwd, {
|
|
181
224
|
signal,
|
|
@@ -205,6 +248,6 @@ export function buildInsertToolDef(): InsertToolDef {
|
|
|
205
248
|
};
|
|
206
249
|
}
|
|
207
250
|
|
|
208
|
-
export function regInsert(pi: ExtensionAPI): void {
|
|
209
|
-
pi.registerTool(buildInsertToolDef());
|
|
251
|
+
export function regInsert(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
252
|
+
pi.registerTool(buildInsertToolDef(flags));
|
|
210
253
|
}
|
package/src/payload-contract.ts
CHANGED
|
@@ -21,6 +21,10 @@ const removeToSchema = Type.String({
|
|
|
21
21
|
description:
|
|
22
22
|
"Bare 4-char anchor from a read row (the text before the `│` separator), never the row content. Marks the LAST line to remove (inclusive)",
|
|
23
23
|
});
|
|
24
|
+
const pathRequiredSchema = Type.String({
|
|
25
|
+
description:
|
|
26
|
+
"Path to the file the anchors were served for; required and must match anchor ownership. Anchors still resolve the target.",
|
|
27
|
+
});
|
|
24
28
|
|
|
25
29
|
export const editToolSchema = Type.Object(
|
|
26
30
|
{
|
|
@@ -31,19 +35,35 @@ export const editToolSchema = Type.Object(
|
|
|
31
35
|
{ additionalProperties: false },
|
|
32
36
|
);
|
|
33
37
|
|
|
38
|
+
export function buildEditToolSchema(requirePath: boolean): typeof editToolSchema {
|
|
39
|
+
if (!requirePath) return editToolSchema;
|
|
40
|
+
return Type.Object(
|
|
41
|
+
{
|
|
42
|
+
path: pathRequiredSchema,
|
|
43
|
+
remove_from: removeFromSchema,
|
|
44
|
+
remove_to: removeToSchema,
|
|
45
|
+
replacement_lines: replacementLinesSchema,
|
|
46
|
+
},
|
|
47
|
+
{ additionalProperties: false },
|
|
48
|
+
) as typeof editToolSchema;
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
export type ReqParams = {
|
|
52
|
+
path?: string;
|
|
35
53
|
remove_from: string;
|
|
36
54
|
remove_to: string;
|
|
37
55
|
replacement_lines: string[];
|
|
38
56
|
};
|
|
39
57
|
|
|
40
|
-
const ROOT_KS = new Set(["remove_from", "remove_to", "replacement_lines"]);
|
|
41
|
-
|
|
58
|
+
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
42
59
|
export function assertReq(request: unknown): asserts request is ReqParams {
|
|
43
60
|
if (!isRec(request)) {
|
|
44
61
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
45
62
|
}
|
|
46
63
|
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
64
|
+
if (request.path !== undefined && typeof request.path !== "string") {
|
|
65
|
+
throw new Error('[E_BAD_SHAPE] Edit request field "path" must be a string when provided.');
|
|
66
|
+
}
|
|
47
67
|
if (
|
|
48
68
|
typeof request.remove_from !== "string" ||
|
|
49
69
|
typeof request.remove_to !== "string" ||
|
package/src/read.ts
CHANGED
|
@@ -15,18 +15,16 @@ import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashl
|
|
|
15
15
|
import { toCwd } from "./paths";
|
|
16
16
|
import { abortIf, makePrepareArguments, numberedRead, visLines, splitLines } from "./utils";
|
|
17
17
|
import { loadP, loadGuide } from "./prompts";
|
|
18
|
+
import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
18
19
|
import { valAccess } from "./validation";
|
|
19
20
|
import { markServed as markServedScoped } from "./anchor-registry";
|
|
20
21
|
import { buildServedMap } from "./served";
|
|
21
22
|
import { Text } from "@earendil-works/pi-tui";
|
|
22
23
|
const R_DESC = loadP("../prompts/read.md");
|
|
23
|
-
|
|
24
24
|
const R_SNIPPET = loadP("../prompts/read-snippet.md");
|
|
25
|
-
|
|
26
25
|
function readGuide(): string[] {
|
|
27
|
-
|
|
26
|
+
return loadGuide("../prompts/read-guidelines.md");
|
|
28
27
|
}
|
|
29
|
-
|
|
30
28
|
function normPosInt(
|
|
31
29
|
value: number | undefined,
|
|
32
30
|
name: "offset" | "limit",
|
|
@@ -166,13 +164,14 @@ export async function fmtReadPreview(
|
|
|
166
164
|
};
|
|
167
165
|
}
|
|
168
166
|
|
|
169
|
-
export function regRead(pi: ExtensionAPI): void {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
167
|
+
export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
168
|
+
const prompted = withReadPrompts({ description: R_DESC, snippet: R_SNIPPET, guidelines: readGuide() }, flags);
|
|
169
|
+
pi.registerTool({
|
|
170
|
+
name: "read",
|
|
171
|
+
label: "Read",
|
|
172
|
+
description: prompted.description,
|
|
173
|
+
promptSnippet: prompted.snippet,
|
|
174
|
+
promptGuidelines: prompted.guidelines,
|
|
176
175
|
prepareArguments: makePrepareArguments(),
|
|
177
176
|
parameters: Type.Object({
|
|
178
177
|
path: Type.String({
|
package/src/replace-render.ts
CHANGED
|
@@ -128,7 +128,7 @@ export function isApplied(
|
|
|
128
128
|
|
|
129
129
|
const RESULT_PREVIEW_LINES = 16;
|
|
130
130
|
|
|
131
|
-
function expandHint(): string {
|
|
131
|
+
export function expandHint(): string {
|
|
132
132
|
try {
|
|
133
133
|
return keyHint("app.tools.expand", "to expand");
|
|
134
134
|
} catch {
|
|
@@ -165,7 +165,7 @@ export function buildAppliedText(
|
|
|
165
165
|
: "";
|
|
166
166
|
sections.push(`${diffSection}${hint}`);
|
|
167
167
|
}
|
|
168
|
-
const warnings = extractWarnings(text);
|
|
168
|
+
const warnings = details?.warnings?.length ? `Warnings:\n${details.warnings.join("\n")}` : extractWarnings(text);
|
|
169
169
|
if (warnings) sections.push(warnings);
|
|
170
170
|
return sections.length > 0 ? sections.join("\n\n") : undefined;
|
|
171
171
|
}
|
package/src/replace-response.ts
CHANGED
|
@@ -125,7 +125,8 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
|
125
125
|
firstChangedLine: undefined,
|
|
126
126
|
snapshotId,
|
|
127
127
|
classification: "noop" as const,
|
|
128
|
-
|
|
128
|
+
metrics,
|
|
129
|
+
...(warnings?.length ? { warnings: [...warnings] } : {}),
|
|
129
130
|
},
|
|
130
131
|
};
|
|
131
132
|
}
|
|
@@ -210,6 +211,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
|
|
|
210
211
|
snapshotId,
|
|
211
212
|
metrics,
|
|
212
213
|
diffLineNumbers: diffResult.lineNumbers,
|
|
214
|
+
...(warnings?.length ? { warnings: [...warnings] } : {}),
|
|
213
215
|
},
|
|
214
216
|
};
|
|
215
217
|
}
|
package/src/replace.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type LineEnding,
|
|
10
10
|
} from "./replace-diff";
|
|
11
11
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
12
|
-
import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
12
|
+
import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
13
|
import { decodeStringArray } from "./utils";
|
|
14
14
|
import { loadP, loadGuide } from "./prompts";
|
|
15
15
|
import { type FileIdentity } from "./fs-write";
|
|
@@ -33,7 +33,7 @@ import { adoptAnchors, servedForPath } from "./anchor-registry";
|
|
|
33
33
|
import { resolveTarget } from "./fs-write";
|
|
34
34
|
import { toCwd } from "./paths";
|
|
35
35
|
import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
|
|
36
|
-
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper,
|
|
36
|
+
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, isBoundaryDedupEnabled, withReplacePrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
37
37
|
|
|
38
38
|
export { editToolSchema, type ReqParams, assertReq };
|
|
39
39
|
|
|
@@ -46,6 +46,7 @@ export type ReplaceDetails = {
|
|
|
46
46
|
classification?: "noop";
|
|
47
47
|
metrics?: RMetrics;
|
|
48
48
|
diffLineNumbers?: (number|undefined)[];
|
|
49
|
+
warnings?: string[];
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
export interface PipelineResult {
|
|
@@ -142,6 +143,8 @@ export async function execPipeline(
|
|
|
142
143
|
);
|
|
143
144
|
const displayPath = relative(cwd, absolutePath).replace(/\\/g, "/") || targetPath;
|
|
144
145
|
|
|
146
|
+
const dedupEnabled = await isBoundaryDedupEnabled();
|
|
147
|
+
const effectiveSkipBoundaryDedup = options?.skipBoundaryDedup === true || !dedupEnabled;
|
|
145
148
|
let anchorResult: ReturnType<typeof applyEdit>;
|
|
146
149
|
try {
|
|
147
150
|
anchorResult = applyEdit(
|
|
@@ -151,7 +154,7 @@ export async function execPipeline(
|
|
|
151
154
|
originalHashes,
|
|
152
155
|
displayPath,
|
|
153
156
|
served,
|
|
154
|
-
|
|
157
|
+
effectiveSkipBoundaryDedup,
|
|
155
158
|
);
|
|
156
159
|
} catch (error) {
|
|
157
160
|
await noteAnchorError(absolutePath, error, originalHashes, options?.noPersist);
|
|
@@ -168,6 +171,7 @@ export async function execPipeline(
|
|
|
168
171
|
hashes: originalHashes,
|
|
169
172
|
}, hashStore, false, true);
|
|
170
173
|
const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
|
|
174
|
+
await throwIfStrictInput(warnings);
|
|
171
175
|
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|
|
172
176
|
edit, originalHashes, isNoop, anchorResult.autoFixes?.length ?? 0,
|
|
173
177
|
);
|
|
@@ -220,7 +224,12 @@ export async function compPreview(
|
|
|
220
224
|
try {
|
|
221
225
|
const normalized = normReq(request);
|
|
222
226
|
assertReq(normalized);
|
|
223
|
-
const targetPath =
|
|
227
|
+
const targetPath = await resolveEditTargetWithRequirement({
|
|
228
|
+
removeFrom: (normalized as ReqParams).remove_from,
|
|
229
|
+
removeTo: (normalized as ReqParams).remove_to,
|
|
230
|
+
providedPath: (normalized as ReqParams).path,
|
|
231
|
+
cwd,
|
|
232
|
+
});
|
|
224
233
|
const pipe = await execPipeline(
|
|
225
234
|
targetPath,
|
|
226
235
|
normalized,
|
|
@@ -240,18 +249,20 @@ type ToolDef = ToolDefinition<
|
|
|
240
249
|
RRState
|
|
241
250
|
> & { renderShell?: "default" | "self" };
|
|
242
251
|
|
|
243
|
-
export function buildToolDef(): ToolDef {
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
252
|
+
export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef {
|
|
253
|
+
const prompted = withReplacePrompts({
|
|
254
|
+
description: loadP("../prompts/replace.md"),
|
|
255
|
+
snippet: loadP("../prompts/replace-snippet.md"),
|
|
256
|
+
guidelines: loadGuide("../prompts/replace-guidelines.md"),
|
|
257
|
+
}, flags);
|
|
258
|
+
const parameters = buildEditToolSchema(flags.requirePath);
|
|
248
259
|
return {
|
|
249
260
|
name: "replace",
|
|
250
261
|
label: "Replace",
|
|
251
|
-
description:
|
|
262
|
+
description: prompted.description,
|
|
252
263
|
parameters,
|
|
253
|
-
promptSnippet:
|
|
254
|
-
promptGuidelines:
|
|
264
|
+
promptSnippet: prompted.snippet,
|
|
265
|
+
promptGuidelines: prompted.guidelines,
|
|
255
266
|
...editToolBase,
|
|
256
267
|
renderCall: editRenderCallWrapper(compPreview),
|
|
257
268
|
renderResult: editRenderResultWrapper,
|
|
@@ -259,10 +270,16 @@ export function buildToolDef(): ToolDef {
|
|
|
259
270
|
const canonical = normReq(params);
|
|
260
271
|
assertReq(canonical);
|
|
261
272
|
const normalizedParams = canonical;
|
|
262
|
-
const targetPath =
|
|
273
|
+
const targetPath = await resolveEditTargetWithRequirement({
|
|
274
|
+
removeFrom: normalizedParams.remove_from,
|
|
275
|
+
removeTo: normalizedParams.remove_to,
|
|
276
|
+
providedPath: normalizedParams.path,
|
|
277
|
+
cwd: ctx.cwd,
|
|
278
|
+
});
|
|
263
279
|
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
280
|
+
const dedupOn = await isBoundaryDedupEnabled();
|
|
264
281
|
const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
|
|
265
|
-
const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
|
|
282
|
+
const boundaryBypass = dedupOn ? consumeBoundaryBypass(mutationTargetPath, noopPayload) : false;
|
|
266
283
|
const pipe = await execPipeline(
|
|
267
284
|
targetPath,
|
|
268
285
|
normalizedParams,
|
|
@@ -279,14 +296,14 @@ export function buildToolDef(): ToolDef {
|
|
|
279
296
|
editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
|
|
280
297
|
signal,
|
|
281
298
|
appliedWarnings,
|
|
282
|
-
onApplied: () => clearBoundaryBypass(mutationTargetPath),
|
|
283
|
-
onNoopDedup: () => markBoundaryNoop(mutationTargetPath, noopPayload),
|
|
299
|
+
onApplied: () => { if (dedupOn) clearBoundaryBypass(mutationTargetPath); },
|
|
300
|
+
onNoopDedup: dedupOn ? () => markBoundaryNoop(mutationTargetPath, noopPayload) : undefined,
|
|
284
301
|
});
|
|
285
302
|
});
|
|
286
303
|
},
|
|
287
304
|
};
|
|
288
305
|
}
|
|
289
306
|
|
|
290
|
-
export function regReplace(pi: ExtensionAPI): void {
|
|
291
|
-
pi.registerTool(buildToolDef());
|
|
307
|
+
export function regReplace(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
308
|
+
pi.registerTool(buildToolDef(flags));
|
|
292
309
|
}
|