pi-hashline-edit-pro 4.5.0 → 4.5.2
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 +11 -11
- package/index.ts +6 -3
- package/package.json +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/replace.md +1 -1
- package/src/config-ui.ts +1 -1
- package/src/grep.ts +19 -2
- package/src/insert.ts +3 -3
- package/src/payload-contract.ts +1 -1
- package/src/replace-render.ts +11 -3
- package/src/replace-response.ts +23 -8
- package/src/replace.ts +3 -2
- package/src/utils.ts +2 -2
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
|
|
|
41
41
|
- [Configuration](#configuration)
|
|
42
42
|
- [Limits](#limits)
|
|
43
43
|
- [Tool result details](#tool-result-details)
|
|
44
|
-
- [Error and
|
|
44
|
+
- [Error, warning, and hint codes](#error-warning-and-hint-codes)
|
|
45
45
|
- [Troubleshooting](#troubleshooting)
|
|
46
46
|
- [Privacy and on-disk state](#privacy-and-on-disk-state)
|
|
47
47
|
- [How anchors work](#how-anchors-work)
|
|
@@ -186,7 +186,7 @@ Edge cases:
|
|
|
186
186
|
| --- | --- |
|
|
187
187
|
| `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
|
|
188
188
|
| `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
|
|
189
|
-
| `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending;
|
|
189
|
+
| `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
|
|
190
190
|
|
|
191
191
|
Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
192
192
|
|
|
@@ -220,7 +220,7 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
|
|
|
220
220
|
| --- | --- |
|
|
221
221
|
| `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
|
|
222
222
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
223
|
-
| `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending;
|
|
223
|
+
| `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A lone string is split on newlines, and stringified array text is unwrapped. |
|
|
224
224
|
|
|
225
225
|
Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
226
226
|
|
|
@@ -357,7 +357,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
357
357
|
| `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
|
|
358
358
|
| `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
|
|
359
359
|
| `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
|
|
360
|
-
| `requirePath` | Require path | `false` | `replace` and `
|
|
360
|
+
| `requirePath` | Require path | `false` | `replace`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
|
|
361
361
|
| `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
|
|
362
362
|
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
363
363
|
|
|
@@ -369,7 +369,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
369
369
|
| --- | --- | --- |
|
|
370
370
|
| Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
|
|
371
371
|
| Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
|
|
372
|
-
| Line cap | 1,353,139 lines per file | `read`, `replace`, `insert` (`[E_FILE_TOO_LARGE]`) |
|
|
372
|
+
| Line cap | 1,353,139 lines per file | `read`, `replace`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
|
|
373
373
|
| File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
|
|
374
374
|
| Hash window | first 500 bytes of a line | anchor identity for long lines |
|
|
375
375
|
| Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
|
|
@@ -390,16 +390,16 @@ All seven tools return machine-readable metadata in `details` alongside the mode
|
|
|
390
390
|
| Tool | `details` |
|
|
391
391
|
| --- | --- |
|
|
392
392
|
| `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
|
|
393
|
-
| `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+anchor│` and ` anchor│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
|
|
393
|
+
| `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+anchor│` and ` anchor│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), `hints` (informative `[H_*]` notices, for example literal escaped text written as sent), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
|
|
394
394
|
| `copy`, `move` | Same shape as `replace`: `diff` (post-edit diff with current anchors), `patch`, `patchTruncated`, `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when a move changes nothing), and `metrics` with the same counters. |
|
|
395
395
|
| `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
|
|
396
396
|
| `anchor_grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
|
|
397
397
|
|
|
398
|
-
`snapshotId`, `firstChangedLine`, and `metrics.warnings` are the three fields external consumers most often read; `snapshotId` is a string fingerprint, `firstChangedLine` is a 1-based line number on the result file, and `metrics.warnings` counts the `[W_*]` notices in `details.warnings
|
|
398
|
+
`snapshotId`, `firstChangedLine`, and `metrics.warnings` are the three fields external consumers most often read; `snapshotId` is a string fingerprint, `firstChangedLine` is a 1-based line number on the result file, and `metrics.warnings` counts the `[W_*]` notices in `details.warnings`; hint `[H_*]` notices live in `details.hints` and are not counted.
|
|
399
399
|
|
|
400
|
-
## Error and
|
|
400
|
+
## Error, warning, and hint codes
|
|
401
401
|
|
|
402
|
-
Codes starting with `E_` are errors: nothing was written, with one exception. `File was written; anchor finalization failed` means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice or an anchor-reclaim notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed. `[E_AUTO_READ_ALL]` is informational rather than a failure: the `read` was refused because the file is unchanged since the start-of-session auto-read, so the attached content is still exact.
|
|
402
|
+
Codes starting with `E_` are errors: nothing was written, with one exception. `File was written; anchor finalization failed` means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice or an anchor-reclaim notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed. Codes starting with `H_` are hints: the call succeeded and the file holds exactly what was requested, so the notice is informational, never blocks an edit (not even in strict-input mode), and is reported in `details.hints` instead of `details.warnings`. `[E_AUTO_READ_ALL]` is informational rather than a failure: the `read` was refused because the file is unchanged since the start-of-session auto-read, so the attached content is still exact.
|
|
403
403
|
|
|
404
404
|
Most common, with the fix:
|
|
405
405
|
|
|
@@ -422,8 +422,8 @@ Full reference:
|
|
|
422
422
|
| `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
|
|
423
423
|
| `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
424
424
|
| `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
425
|
-
| `[W_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escape text such as `\u200b` or `\n`; the file receives those characters as written. Decode the escapes first if the text came from a quoted prompt. |
|
|
426
425
|
| `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
|
|
426
|
+
| `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escaped text such as `\uXXXX` or `\n`; the file receives those backslash characters as written. Escapes decode once in the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally — resend with the real character if that was not intended. |
|
|
427
427
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
|
|
428
428
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
429
429
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
@@ -479,7 +479,7 @@ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` withou
|
|
|
479
479
|
|
|
480
480
|
### Allocation
|
|
481
481
|
|
|
482
|
-
Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all
|
|
482
|
+
Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
483
483
|
|
|
484
484
|
### Ownership and mapping across edits
|
|
485
485
|
|
package/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-al
|
|
|
11
11
|
import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
|
|
12
12
|
import type { RMetrics } from "./src/replace-response";
|
|
13
13
|
import type { ReplaceDetails } from "./src/replace";
|
|
14
|
-
import { extractWarnings } from "./src/replace-render";
|
|
14
|
+
import { extractHints, extractWarnings } from "./src/replace-render";
|
|
15
15
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
16
16
|
import type { AutoReadAllMode } from "./src/config";
|
|
17
17
|
import {
|
|
@@ -266,6 +266,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
266
266
|
const toolDetails = event.details as ReplaceDetails | undefined;
|
|
267
267
|
const diff = toolDetails?.diff;
|
|
268
268
|
const detailWarnings = Array.isArray(toolDetails?.warnings) ? toolDetails.warnings.filter((w): w is string => typeof w === "string") : [];
|
|
269
|
+
const detailHints = Array.isArray(toolDetails?.hints) ? toolDetails.hints.filter((h): h is string => typeof h === "string") : [];
|
|
269
270
|
if (typeof diff !== "string") return;
|
|
270
271
|
const hasDiff = diff.length > 0;
|
|
271
272
|
|
|
@@ -277,12 +278,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
277
278
|
.map((entry) => entry.text)
|
|
278
279
|
.join("\n");
|
|
279
280
|
const warnings = detailWarnings.length ? `Warnings:\n${detailWarnings.join("\n")}` : extractWarnings(rendered);
|
|
280
|
-
const
|
|
281
|
+
const hints = detailHints.length ? `Hints:\n${detailHints.join("\n")}` : extractHints(rendered);
|
|
282
|
+
const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
|
|
283
|
+
const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `[post-edit] applied successfully; the diff is empty (whitespace-only change).\n\n${notices}` : "[post-edit] applied successfully; the diff is empty (whitespace-only change).";
|
|
281
284
|
return {
|
|
282
285
|
content: [
|
|
283
286
|
{
|
|
284
287
|
type: "text",
|
|
285
|
-
text:
|
|
288
|
+
text: noticeText,
|
|
286
289
|
},
|
|
287
290
|
],
|
|
288
291
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.2",
|
|
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",
|
package/prompts/insert.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending;
|
|
1
|
+
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Inserted lines are written exactly as given; nothing else in the file changes.
|
|
2
2
|
|
|
3
3
|
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
package/prompts/replace.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending;
|
|
1
|
+
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. `[]` deletes the range.
|
|
2
2
|
|
|
3
3
|
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
4
4
|
|
package/src/config-ui.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
24
24
|
{ key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
|
|
25
25
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
26
26
|
{ key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
|
|
27
|
-
{ key: "requirePath", label: "Require path", hint: "replace
|
|
27
|
+
{ key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
|
|
28
28
|
{ key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
29
29
|
];
|
|
30
30
|
}
|
package/src/grep.ts
CHANGED
|
@@ -298,14 +298,30 @@ export async function resolveRgPath(): Promise<string> {
|
|
|
298
298
|
throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
async function insideGitRepo(start: string): Promise<boolean> {
|
|
302
|
+
let current = start;
|
|
303
|
+
for (;;) {
|
|
304
|
+
try {
|
|
305
|
+
await stat(join(current, ".git"));
|
|
306
|
+
return true;
|
|
307
|
+
} catch {
|
|
308
|
+
const parent = dirname(current);
|
|
309
|
+
if (parent === current) return false;
|
|
310
|
+
current = parent;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
301
315
|
async function collectRgMatches(
|
|
302
316
|
rgPath: string,
|
|
303
317
|
pattern: string,
|
|
304
318
|
searchPath: string,
|
|
305
319
|
req: GrepReq,
|
|
306
|
-
signal
|
|
320
|
+
signal: AbortSignal | undefined,
|
|
321
|
+
repoRooted: boolean,
|
|
307
322
|
): Promise<Map<string, number[]>> {
|
|
308
323
|
const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
|
|
324
|
+
if (!repoRooted) args.push("--no-require-git");
|
|
309
325
|
const wanted = req.limit ?? 100;
|
|
310
326
|
args.push("--max-count", String(wanted + 1));
|
|
311
327
|
if (typeof req.glob === "string" && req.glob.length > 0) {
|
|
@@ -583,7 +599,8 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
583
599
|
};
|
|
584
600
|
const readGrepFile = makeGrepReader("real");
|
|
585
601
|
const readGrepFileShadow = makeGrepReader("shadow");
|
|
586
|
-
const
|
|
602
|
+
const repoRooted = await insideGitRepo(globRoot);
|
|
603
|
+
const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
|
|
587
604
|
const sortedFiles = [...rgMatches.keys()].sort(cmp);
|
|
588
605
|
for (let f = 0; f < sortedFiles.length; f++) {
|
|
589
606
|
abortIf(signal);
|
package/src/insert.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { stripAnchorRow } from "./hashline/resolve";
|
|
|
11
11
|
import { withAnchorSession } from "./anchor-registry";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
14
|
-
import { decodeStringArray, isRec,
|
|
14
|
+
import { decodeStringArray, isRec, literalEscapeHint, splitLines } from "./utils";
|
|
15
15
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
16
16
|
import type { RPreview, RRState } from "./replace-render";
|
|
17
17
|
export { assertInsertReq, type InsertReq };
|
|
@@ -28,7 +28,7 @@ const insertDirectionSchema = Type.Union(
|
|
|
28
28
|
|
|
29
29
|
const insertLinesSchema = Type.Array(
|
|
30
30
|
Type.String({
|
|
31
|
-
description: "One line to insert. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings
|
|
31
|
+
description: "One line to insert. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings.",
|
|
32
32
|
}),
|
|
33
33
|
{
|
|
34
34
|
description: 'One string per line; [""] is a blank line; never include the anchor line.',
|
|
@@ -192,7 +192,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
192
192
|
}
|
|
193
193
|
assertInsertReq(canonical);
|
|
194
194
|
const req = canonical;
|
|
195
|
-
const literalEscape =
|
|
195
|
+
const literalEscape = literalEscapeHint(req.lines, "lines");
|
|
196
196
|
if (literalEscape !== undefined) insertWarnings.push(literalEscape);
|
|
197
197
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
198
198
|
anchor: req.anchor,
|
package/src/payload-contract.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./uti
|
|
|
4
4
|
const replacementLinesSchema = Type.Array(
|
|
5
5
|
Type.String({
|
|
6
6
|
description:
|
|
7
|
-
"One replacement line. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings
|
|
7
|
+
"One replacement line. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings.",
|
|
8
8
|
}),
|
|
9
9
|
{
|
|
10
10
|
description:
|
package/src/replace-render.ts
CHANGED
|
@@ -126,7 +126,13 @@ export function getResultText(result: {
|
|
|
126
126
|
export function extractWarnings(
|
|
127
127
|
text: string | undefined,
|
|
128
128
|
): string | undefined {
|
|
129
|
-
return text?.match(/(?:^|\n)Warnings:\n[\s\S]
|
|
129
|
+
return text?.match(/(?:^|\n)Warnings:\n[\s\S]*?(?=\n\nHints:\n|$)/)?.[0]?.trimStart();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function extractHints(
|
|
133
|
+
text: string | undefined,
|
|
134
|
+
): string | undefined {
|
|
135
|
+
return text?.match(/(?:^|\n)Hints:\n[\s\S]*$/)?.[0]?.trimStart();
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
export function isApplied(
|
|
@@ -153,8 +159,8 @@ export function expandHint(): string {
|
|
|
153
159
|
function extractSummary(text: string | undefined): string | undefined {
|
|
154
160
|
if (!text) return undefined;
|
|
155
161
|
if (text.includes("│")) return undefined;
|
|
156
|
-
const
|
|
157
|
-
const summary =
|
|
162
|
+
const noticesIdx = text.search(/\n\n(?:Warnings|Hints):\n/);
|
|
163
|
+
const summary = noticesIdx >= 0 ? text.slice(0, noticesIdx) : text;
|
|
158
164
|
return summary.length > 0 ? summary : undefined;
|
|
159
165
|
}
|
|
160
166
|
|
|
@@ -190,6 +196,8 @@ export function buildAppliedText(
|
|
|
190
196
|
}
|
|
191
197
|
const warnings = details?.warnings?.length ? `Warnings:\n${details.warnings.join("\n")}` : extractWarnings(text);
|
|
192
198
|
if (warnings) sections.push(warnings);
|
|
199
|
+
const hints = details?.hints?.length ? `Hints:\n${details.hints.join("\n")}` : extractHints(text);
|
|
200
|
+
if (hints) sections.push(hints);
|
|
193
201
|
return sections.length > 0 ? sections.join("\n\n") : undefined;
|
|
194
202
|
}
|
|
195
203
|
|
package/src/replace-response.ts
CHANGED
|
@@ -85,6 +85,17 @@ function warnBlock(warnings: string[] | undefined): string {
|
|
|
85
85
|
return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function hintBlock(hints: string[] | undefined): string {
|
|
89
|
+
return hints?.length ? `\n\nHints:\n${hints.join("\n")}` : "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function splitNotices(notices: string[] | undefined): { warnings: string[]; hints: string[] } {
|
|
93
|
+
const warnings: string[] = [];
|
|
94
|
+
const hints: string[] = [];
|
|
95
|
+
for (const notice of notices ?? []) (notice.startsWith("[H_") ? hints : warnings).push(notice);
|
|
96
|
+
return { warnings, hints };
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
89
100
|
const {
|
|
90
101
|
path,
|
|
@@ -97,13 +108,14 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
|
97
108
|
const noopDetailsText = noopEdit
|
|
98
109
|
? `${noopNoun} for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
|
|
99
110
|
: "The edit produced identical content.";
|
|
100
|
-
const
|
|
111
|
+
const { warnings: noticeWarnings, hints } = splitNotices(warnings);
|
|
112
|
+
const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(noticeWarnings)}${hintBlock(hints)}`;
|
|
101
113
|
|
|
102
114
|
const metrics = buildMetrics({
|
|
103
115
|
classification: "noop",
|
|
104
116
|
editsAttempted: editMeta.editsAttempted,
|
|
105
117
|
noopEditsCount: editMeta.noopEditsCount,
|
|
106
|
-
warningsCount:
|
|
118
|
+
warningsCount: noticeWarnings.length,
|
|
107
119
|
});
|
|
108
120
|
|
|
109
121
|
return {
|
|
@@ -115,7 +127,8 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
|
115
127
|
snapshotId,
|
|
116
128
|
classification: "noop" as const,
|
|
117
129
|
metrics,
|
|
118
|
-
...(
|
|
130
|
+
...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
|
|
131
|
+
...(hints.length ? { hints: [...hints] } : {}),
|
|
119
132
|
},
|
|
120
133
|
};
|
|
121
134
|
}
|
|
@@ -126,22 +139,23 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
126
139
|
const diffResult = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes, undefined, spans);
|
|
127
140
|
const addedLines = editMeta.addedLines;
|
|
128
141
|
const removedLines = editMeta.removedLines;
|
|
129
|
-
const
|
|
142
|
+
const { warnings: noticeWarnings, hints } = splitNotices(warnings);
|
|
143
|
+
const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(hints)}`;
|
|
130
144
|
const successPrefix = `Successfully ${verb} in ${path}.`;
|
|
131
145
|
const lineSummary = addedLines > 0 || removedLines > 0
|
|
132
146
|
? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
|
|
133
147
|
: "";
|
|
134
148
|
const text = resultLines.length === 0
|
|
135
149
|
? "File is empty. Use replace to insert content."
|
|
136
|
-
:
|
|
137
|
-
? `${successPrefix}${lineSummary}${
|
|
150
|
+
: noticesBlock
|
|
151
|
+
? `${successPrefix}${lineSummary}${noticesBlock}`
|
|
138
152
|
: `${successPrefix}${lineSummary}`;
|
|
139
153
|
|
|
140
154
|
const metrics = buildMetrics({
|
|
141
155
|
classification: "applied",
|
|
142
156
|
editsAttempted: editMeta.editsAttempted,
|
|
143
157
|
noopEditsCount: editMeta.noopEditsCount,
|
|
144
|
-
warningsCount:
|
|
158
|
+
warningsCount: noticeWarnings.length,
|
|
145
159
|
firstChangedLine: editMeta.firstChangedLine,
|
|
146
160
|
lastChangedLine: editMeta.lastChangedLine,
|
|
147
161
|
addedLines,
|
|
@@ -160,7 +174,8 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
160
174
|
snapshotId,
|
|
161
175
|
metrics,
|
|
162
176
|
diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
|
|
163
|
-
...(
|
|
177
|
+
...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
|
|
178
|
+
...(hints.length ? { hints: [...hints] } : {}),
|
|
164
179
|
},
|
|
165
180
|
};
|
|
166
181
|
}
|
package/src/replace.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./replace-diff";
|
|
11
11
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
12
12
|
import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
|
-
import {
|
|
13
|
+
import { literalEscapeHint, splitLines } from "./utils";
|
|
14
14
|
import { loadP, loadGuide } from "./prompts";
|
|
15
15
|
import { type FileIdentity } from "./fs-write";
|
|
16
16
|
import { applyEdit,
|
|
@@ -48,6 +48,7 @@ export type ReplaceDetails = {
|
|
|
48
48
|
metrics?: RMetrics;
|
|
49
49
|
diffLineNumbers?: (number | null)[];
|
|
50
50
|
warnings?: string[];
|
|
51
|
+
hints?: string[];
|
|
51
52
|
batch?: { id: number; size: number; last: boolean; total: number; aborted?: boolean; abortMessage?: string };
|
|
52
53
|
};
|
|
53
54
|
|
|
@@ -273,7 +274,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
273
274
|
const canonical = normReq(params);
|
|
274
275
|
assertReq(canonical);
|
|
275
276
|
const normalizedParams = canonical;
|
|
276
|
-
const literalEscape =
|
|
277
|
+
const literalEscape = literalEscapeHint(normalizedParams.replacement_lines, "replacement_lines");
|
|
277
278
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
278
279
|
removeFrom: normalizedParams.remove_from,
|
|
279
280
|
removeTo: normalizedParams.remove_to,
|
package/src/utils.ts
CHANGED
|
@@ -384,7 +384,7 @@ function isSurrogateEscapePair(line: string, index: number, hex: string): boolea
|
|
|
384
384
|
return false;
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
export function
|
|
387
|
+
export function literalEscapeHint(lines: string[], label: string): string | undefined {
|
|
388
388
|
for (const line of lines) {
|
|
389
389
|
if (!line.includes("\\")) continue;
|
|
390
390
|
const hasRealBreak = REAL_LINE_BREAK_RE.test(line);
|
|
@@ -396,7 +396,7 @@ export function literalEscapeWarning(lines: string[], label: string): string | u
|
|
|
396
396
|
if (hex.toLowerCase() === "dddd") continue;
|
|
397
397
|
if (isSurrogateEscapePair(line, match.index, hex)) continue;
|
|
398
398
|
}
|
|
399
|
-
return `[
|
|
399
|
+
return `[H_LITERAL_ESCAPE] "${label}" contains the literal escaped text "${match[0]}"`;
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
402
|
return undefined;
|