pi-hashline-edit-pro 2.8.2 → 2.8.4

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![npm downloads](https://img.shields.io/npm/dm/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro)
4
4
 
5
- Anchor-based `read`, `replace`, `insert`, and `grep` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character anchor, and you edit by anchor. There are no line numbers and no fuzzy matching, so edits land on the lines you meant.
5
+ Anchor-based `read`, `replace`, `insert`, and `anchor_grep` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character anchor, and you edit by anchor. There are no line numbers and no fuzzy matching, so edits land on the lines you meant.
6
6
 
7
7
  Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character anchors and collision resolution.
8
8
 
@@ -11,7 +11,7 @@ Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by Rimur
11
11
  - `read` returns every line as `anchor│content`. The anchor is the line's address.
12
12
  - `replace` targets a range of anchors, so edits land on the lines you meant.
13
13
  - `insert` adds lines after or before a line by anchor: the anchor line is preserved and the new lines are applied literally, never deduplicated.
14
- - `grep` returns matching lines (and requested context) with `anchor│content` rows that are served like read output, so search results are immediately editable.
14
+ - `anchor_grep` returns matching lines (and requested context) with `anchor│content` rows that are served like read output, so search results are immediately editable.
15
15
  - Editing one part of a file leaves the anchors of the rest unchanged, so anchors from an earlier read stay valid across edits.
16
16
  - After a `write` you get the new anchors. After a `replace` or `insert` you get the diff with the new anchors.
17
17
  - The most recent replace or insert on a file can be reverted, even after a restart.
@@ -63,7 +63,7 @@ pi install /path/to/pi-hashline-edit-pro
63
63
 
64
64
  Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
65
65
 
66
- Lines up to 50KB are shown in full. A larger line is replaced by a marker that keeps the line's anchor: `anchor│[Line N is 2.2MB, exceeds 50KB; content not shown. Use bash: sed -n 'Np' <path> | head -c 51200]`. The marker is served like a normal row, so the whole line can still be replaced via that anchor; `grep` shows an anchored fragment around a match on such a line instead.
66
+ Lines up to 50KB are shown in full. A larger line is replaced by a marker that keeps the line's anchor: `anchor│[Line N is 2.2MB, exceeds 50KB; content not shown. Use bash: sed -n 'Np' <path> | head -c 51200]`. The marker is served like a normal row, so the whole line can still be replaced via that anchor; `anchor_grep` shows an anchored fragment around a match on such a line instead.
67
67
 
68
68
  Edge cases:
69
69
 
@@ -125,15 +125,15 @@ Notes:
125
125
 
126
126
  Notes:
127
127
 
128
- - The anchor line must have been shown to you (read output, a post-edit diff row, grep output, or stale-range feedback). The same verification as `replace` applies: a stale or unshown anchor is rejected with `[E_STALE_ANCHOR]`, `[E_AMBIGUOUS_ANCHOR]`, or `[E_RANGE_STALE]` and the retry needs no `read`.
128
+ - The anchor line must have been shown to you (read output, a post-edit diff row, anchor_grep output, or stale-range feedback). The same verification as `replace` applies: a stale or unshown anchor is rejected with `[E_STALE_ANCHOR]`, `[E_AMBIGUOUS_ANCHOR]`, or `[E_RANGE_STALE]` and the retry needs no `read`.
129
129
  - Lines are applied literally: nothing is removed, and a line that duplicates its neighbor is kept. `replace`'s boundary anti-duplication never runs for `insert`.
130
130
  - To seed an empty file, read it and insert after the `anchor│` empty-line row.
131
131
  - The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), line endings and BOMs survive, and an applied insert clears a pending boundary bypass.
132
132
  - Inserting nothing (`lines: []`) reports a noop and leaves the file unchanged; inserted lines are never deduplicated.
133
133
 
134
- ## The grep tool
134
+ ## The anchor_grep tool
135
135
 
136
- `grep` replaces the built-in grep with an anchored search backed by ripgrep. 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`/`insert` without a separate `read`, while the line-number gutter and `=== path ===` header give filename and line for navigation (press Return to jump).
136
+ `anchor_grep` replaces the built-in grep with an anchored search backed by ripgrep. While `anchor_grep` is enabled, the built-in grep is disabled; disabling `anchor_grep` restores it if it was active before the extension loaded. 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`/`insert` without a separate `read`, while the line-number gutter and `=== path ===` header give filename and line for navigation (press Return to jump).
137
137
 
138
138
  | Field | Description |
139
139
  | --- | --- |
@@ -147,12 +147,13 @@ Notes:
147
147
 
148
148
  Notes:
149
149
  - Results are grouped per file under a `=== path ===` header; every shown row is `lineNumber │ anchor│content` where `anchor│content` is the anchor it would have in `read` output.
150
- - Directory searches use ripgrep, respecting `.gitignore`; `node_modules`, `.git`, `.tmp`, and `coverage` are always skipped. Binary, image, and oversized files are skipped silently.
150
+ - Directory searches use ripgrep, respecting `.gitignore` (including parent directories); `.git` is always skipped, and hidden files are searched, so `node_modules`, `.tmp`, and `coverage` are skipped only when a `.gitignore` lists them. Binary, image, and oversized files are skipped silently.
151
151
  - Regex patterns with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned; use `literal: true` when regex behavior is unnecessary.
152
152
  - Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line).
153
153
  - `file_path` works as an alias for `path`.
154
154
  - Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
155
155
  - Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
156
+ - The anchor_grep tool is enabled by default. Disable it with `/toggle-anchor-grep` (or set `anchorGrepEnabled` to `false` in the config file); the setting persists across sessions. When disabled, anchor_grep is removed from the model's toolset and the built-in grep is restored only if it was active before the extension loaded — a grep tool that was never enabled stays off.
156
157
 
157
158
  ## Undo
158
159
 
@@ -171,7 +172,7 @@ Notes:
171
172
  Enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `anchor│content` anchors without a separate `read` call.
172
173
 
173
174
  - 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, so you can see exactly which anchors were deleted (those anchors are stale after the edit). When the context line touching 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.
174
- - Auto-read keeps the same 50KB / 2000-line budget as `read`. Lines over 50KB are shown as markers that keep the line's anchor (use `grep` for a fragment around a match).
175
+ - Auto-read keeps the same 50KB / 2000-line budget as `read`. Lines over 50KB are shown as markers that keep the line's anchor (use `anchor_grep` for a fragment around a match).
175
176
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
176
177
 
177
178
  ## Tool result details
@@ -181,19 +182,21 @@ All five tools return machine-readable metadata in `details` alongside the model
181
182
  - `read`: `details.truncation` (set when the output was truncated), `details.snapshotId` (a `v2|path|ino|mtime|ctime|size` fingerprint of the file), `details.nextOffset` (use as the next `offset`), and `details.metrics` with `truncated` and `next_offset`.
182
183
  - `replace` and `insert`: `details.diff` (the post-edit diff, capped at 50KB with markers for oversized rows; `+HASH│` and ` HASH│` rows carry the current anchors), `details.patch` (a standard unified patch of the changes, for external tools, capped at 50KB like the diff), `details.patchTruncated` (true when the patch was cut to fit the cap and cannot be applied as-is), `details.firstChangedLine`, `details.snapshotId`, `details.classification` (`"noop"` when nothing changed), and `details.metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`.
183
184
  - `undo_last_change`: `details.diff` (the undo diff with the restored anchors), `details.patch` (a standard unified patch of the restored changes, capped at 50KB like `replace`), `details.patchTruncated` (true when the patch was cut), and `details.metrics` (same shape as `replace`).
184
- - `grep`: `details.metrics` with `matches` (matched lines found, capped at `limit`), `files`, and `truncated` (true when the row, byte, file-scan, or `limit` cap cut the results), plus `details.truncation` (the standard pi truncation report — `truncatedBy`, `totalLines`, `outputLines`, `maxLines`, `maxBytes`, … — when the output was cut) and `details.linesTruncated` (true when long lines were shown as fragments).
185
+ - `anchor_grep`: `details.metrics` with `matches` (matched lines found, capped at `limit`), `files`, and `truncated` (true when the row, byte, file-scan, or `limit` cap cut the results), plus `details.truncation` (the standard pi truncation report — `truncatedBy`, `totalLines`, `outputLines`, `maxLines`, `maxBytes`, … — when the output was cut) and `details.linesTruncated` (true when long lines were shown as fragments).
185
186
 
186
187
  ## Settings
187
188
 
188
189
  | Command | Description |
189
190
  | --- | --- |
190
191
  | `/toggle-auto-read` | Toggle auto-read anchors after write and post-edit diffs after replace, insert, and undo_last_change. Persists across sessions. |
192
+ | `/toggle-anchor-grep` | Enable or disable the anchor_grep tool (the built-in grep is disabled while anchor_grep is on). Persists across sessions. |
191
193
 
192
194
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatically when a setting is toggled. On non-Windows platforms, the config directory honors `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows it always uses `~/.config`:
193
195
 
194
196
  ```json
195
197
  {
196
- "autoRead": true
198
+ "autoRead": true,
199
+ "anchorGrepEnabled": true
197
200
  }
198
201
  ```
199
202
 
package/index.ts CHANGED
@@ -12,6 +12,7 @@ import { MAX_HASH_LINES } from "./src/hashline";
12
12
  import {
13
13
  readConfig,
14
14
  toggleAutoRead,
15
+ toggleAnchorGrep,
15
16
  } from "./src/config";
16
17
  import { loadHashStore, pruneMissing } from "./src/hash-store";
17
18
  import { recordServedSafe, clearServed, buildServedMap } from "./src/served";
@@ -33,9 +34,11 @@ export default function (pi: ExtensionAPI): void {
33
34
  registerWriteHook(pi);
34
35
 
35
36
  let autoRead = true;
37
+ let grepWasActive = false;
36
38
 
37
39
  pi.on("session_start", async (_event, ctx) => {
38
40
  const active = pi.getActiveTools();
41
+ grepWasActive = active.includes("grep");
39
42
  pi.setActiveTools(active.filter((t) => t !== "edit"));
40
43
  await initHasher();
41
44
  loadHashStore()
@@ -49,6 +52,11 @@ export default function (pi: ExtensionAPI): void {
49
52
  });
50
53
  const config = await readConfig();
51
54
  autoRead = config.autoRead;
55
+ pi.setActiveTools(
56
+ pi.getActiveTools().filter((t) =>
57
+ config.anchorGrepEnabled ? t !== "grep" : t !== "anchor_grep",
58
+ ),
59
+ );
52
60
  const debugValue = process.env.PI_HASHLINE_DEBUG;
53
61
  if (debugValue === "1" || debugValue === "true") {
54
62
  ctx.ui.notify(`Hashline Edit mode active`, "info");
@@ -64,6 +72,21 @@ export default function (pi: ExtensionAPI): void {
64
72
  },
65
73
  });
66
74
 
75
+ pi.registerCommand("toggle-anchor-grep", {
76
+ description: "Enable or disable the anchor_grep tool (the built-in grep is disabled while anchor_grep is on)",
77
+ handler: async (_args, ctx) => {
78
+ const enabled = await toggleAnchorGrep();
79
+ const active = pi.getActiveTools();
80
+ pi.setActiveTools(
81
+ enabled
82
+ ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])]
83
+ : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])],
84
+ );
85
+ const state = enabled ? "enabled" : "disabled";
86
+ ctx.ui.notify(`anchor_grep tool ${state}`, "info");
87
+ },
88
+ });
89
+
67
90
  pi.on("tool_result", async (event, ctx) => {
68
91
  if (event.isError) return;
69
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.8.2",
3
+ "version": "2.8.4",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1,3 +1,3 @@
1
- - `grep`: every hit and `context` line comes back as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` enables jump-to-line.
2
- - `grep`: uses ripgrep, respects `.gitignore`; use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
3
- - `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
1
+ - `anchor_grep`: every hit and `context` line comes back as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` enables jump-to-line.
2
+ - `anchor_grep`: uses ripgrep, respects `.gitignore`; use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
3
+ - `anchor_grep`: `.git` is always skipped; `node_modules`/`.tmp`/`coverage` are skipped only when a `.gitignore` lists them; binary/image files are skipped.
package/prompts/grep.md CHANGED
@@ -1 +1 @@
1
- Search text files for a pattern using ripgrep. Every hit and each `context` line is returned as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` and the `=== path ===` header give file and line for navigation. Respects `.gitignore` via ripgrep; directory search skips `node_modules/.git/.tmp/coverage` and skips binary/image files. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
1
+ Search text files for a pattern using ripgrep. Every hit and each `context` line is returned as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` and the `=== path ===` header give file and line for navigation. Respects `.gitignore` via ripgrep; `.git` is always skipped, and `node_modules`/`.tmp`/`coverage` are skipped only when a `.gitignore` lists them. Binary and image files are skipped silently. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
@@ -1,3 +1,3 @@
1
1
  - `insert`: `anchor` is bare `aB3` from `aB3│content` (never the content), direction `after` adds below, `before` adds above. Do not put the anchor line in `lines`; `[""]` is a blank line.
2
2
  - `insert`: `lines` is bare content, one element per line, kept literally even if it duplicates neighbors — nothing is removed.
3
- - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or `grep`. For an empty file, `read` shows one `anchor│` empty row — insert `after` it.
3
+ - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or `anchor_grep`. For an empty file, `read` shows one `anchor│` empty row — insert `after` it.
@@ -1,2 +1,2 @@
1
1
  - `read`: call before `replace` when you need fresh anchors for a file.
2
- - `read`: call again after an edit when you need anchors you do not have — post-edit diff `+anchor│`/` anchor│` rows and `grep` hits are already fresh anchors for the changed range, so no new `read` needed for those lines.
2
+ - `read`: call again after an edit when you need anchors you do not have — post-edit diff `+anchor│`/` anchor│` rows and `anchor_grep` hits are already fresh anchors for the changed range, so no new `read` needed for those lines.
@@ -1 +1 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 3-character anchors from read output. Saw `aB3│content` in read/diff/grep: `aB3` is the bare HASH before `│`, `aB3│content` is the full row. `remove_from` and `remove_to` are bare HASH only (e.g. `"aB3"`), never the full row or file content. `replacement_lines` is bare content without `│`, one string per line — use `[]` to delete. Example: read showed `aB3│old` and `kQm│old2`, to replace both use `{"remove_from":"aB3","remove_to":"kQm","replacement_lines":["new line 1","new line 2"]}`. Single line → same anchor for both.
1
+ Replace a range of lines (or a single line) in a text file, targeted by 3-character anchors from read output. Saw `aB3│content` in read/diff/anchor_grep: `aB3` is the bare HASH before `│`, `aB3│content` is the full row. `remove_from` and `remove_to` are bare HASH only (e.g. `"aB3"`), never the full row or file content. `replacement_lines` is bare content without `│`, one string per line — use `[]` to delete. Example: read showed `aB3│old` and `kQm│old2`, to replace both use `{"remove_from":"aB3","remove_to":"kQm","replacement_lines":["new line 1","new line 2"]}`. Single line → same anchor for both.
@@ -1,2 +1,2 @@
1
1
  - `undo_last_change`: only the last `replace`/`insert` per file can be undone; a successful `write` clears it, so call immediately after a bad diff (look for `-anchor│` lines you wanted to keep).
2
- - `undo_last_change`: if the file changed after the edit you get `[E_UNDO_STALE]` — the record is kept, so undo again after the file matches the edited state; if the file was deleted it is restored.
2
+ - `undo_last_change`: if the file changed after the edit you get `[E_UNDO_STALE]` — nothing is reverted and the record is kept; do not modify the file to force an undo, verify with `read` and stop; if the file was deleted it is restored.
@@ -1 +1 @@
1
- Undo the last `replace` or `insert` on a file, restoring previous content, BOM and line endings. Use after a bad edit when the diff showed wrong lines removed. If the file was deleted, it is restored; if changed elsewhere, you get `[E_UNDO_STALE]` and the record is kept so you can retry after restoring the edited state. If output says truncated, use `read` to see full file.
1
+ Undo the last `replace` or `insert` on a file, restoring previous content, BOM and line endings. Use after a bad edit when the diff showed wrong lines removed. If the file was deleted, it is restored; if changed elsewhere, you get `[E_UNDO_STALE]`, nothing is reverted, and the record is kept do not modify the file to make an undo possible; call `read` to verify the current state and stop. If output says truncated, use `read` to see full file.
package/src/commit.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PipelineResult } from "./replace";
2
- import { abortIf } from "./utils";
2
+ import { abortIf, clipLine } from "./utils";
3
3
  import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
4
4
  import { saveUndo } from "./replace-undo";
5
5
  import { safeSnapId } from "./file-reader";
@@ -22,6 +22,12 @@ export interface CommitMeta {
22
22
  onNoopDedup?: () => void;
23
23
  }
24
24
 
25
+ function boundaryDedupWarning(lineTexts: string[]): string {
26
+ const quoted = lineTexts.map((line) => `"${clipLine(line, 80)}"`).join(", ");
27
+ const plural = lineTexts.length > 1;
28
+ return `Boundary dedup: ${quoted} already ${plural ? "exist" : "exists"} next to the edited range, so ${plural ? "they were" : "it was"} not added again. Use insert only if you truly want ${plural ? "duplicates" : "a duplicate"}.`;
29
+ }
30
+
25
31
  export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
26
32
  const { path, absolutePath, mutationTargetPath, signal } = meta;
27
33
  const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
@@ -54,6 +60,9 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
54
60
  "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
55
61
  );
56
62
  }
63
+ if (pipe.boundaryRemovedLineTexts.length > 0) {
64
+ warnings.push(boundaryDedupWarning(pipe.boundaryRemovedLineTexts));
65
+ }
57
66
 
58
67
  abortIf(signal);
59
68
  const undo = await saveUndo(mutationTargetPath, {
package/src/config.ts CHANGED
@@ -5,10 +5,12 @@ import { writeAtomic } from "./fs-write";
5
5
 
6
6
  export interface Config {
7
7
  autoRead: boolean;
8
+ anchorGrepEnabled: boolean;
8
9
  }
9
10
 
10
11
  const DEFAULT_CONFIG: Config = {
11
- autoRead: true
12
+ autoRead: true,
13
+ anchorGrepEnabled: true
12
14
  };
13
15
 
14
16
  function parseConfig(content: string): Config {
@@ -17,7 +19,11 @@ function parseConfig(content: string): Config {
17
19
  if (typeof autoRead !== "boolean") {
18
20
  throw new Error("config.json must be an object with a boolean autoRead field");
19
21
  }
20
- return { autoRead };
22
+ const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
23
+ return {
24
+ autoRead,
25
+ anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
26
+ };
21
27
  }
22
28
 
23
29
 
@@ -43,3 +49,10 @@ export async function toggleAutoRead(): Promise<boolean> {
43
49
  await writeConfig(config);
44
50
  return config.autoRead;
45
51
  }
52
+
53
+ export async function toggleAnchorGrep(): Promise<boolean> {
54
+ const config = await readConfig();
55
+ config.anchorGrepEnabled = !config.anchorGrepEnabled;
56
+ await writeConfig(config);
57
+ return config.anchorGrepEnabled;
58
+ }
package/src/grep.ts CHANGED
@@ -313,7 +313,7 @@ async function collectRgMatches(
313
313
  req: GrepReq,
314
314
  signal?: AbortSignal,
315
315
  ): Promise<Map<string, number[]>> {
316
- const args = ["--json", "--line-number", "--color=never", "--hidden"];
316
+ const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
317
317
  if (req.ignoreCase) args.push("--ignore-case");
318
318
  if (req.literal) args.push("--fixed-strings");
319
319
  args.push("--", pattern, searchPath);
@@ -443,8 +443,8 @@ const grepToolSchema = Type.Object(
443
443
 
444
444
  export function regGrep(pi: ExtensionAPI): void {
445
445
  pi.registerTool({
446
- name: "grep",
447
- label: "Grep",
446
+ name: "anchor_grep",
447
+ label: "Anchor Grep",
448
448
  description: loadP("../prompts/grep.md"),
449
449
  promptSnippet: loadP("../prompts/grep-snippet.md"),
450
450
  promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
@@ -560,7 +560,7 @@ export function regGrep(pi: ExtensionAPI): void {
560
560
  hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
561
561
  for (const hit of hits) {
562
562
  const servedMap = buildServedMap(hit.fileHashes, hit.fileLines, hit.hashes);
563
- await recordServedSafe(hit.path, servedMap, "grep", new Set(hit.fileHashes));
563
+ await recordServedSafe(hit.path, servedMap, "anchor_grep", new Set(hit.fileHashes));
564
564
  }
565
565
  const blocks = hits
566
566
  .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
@@ -53,6 +53,23 @@ function parseRef(ref: string): Anchor {
53
53
 
54
54
  export const parseHashRef = parseRef;
55
55
 
56
+ const JSON_ENVELOPE_RE = /^\s*\["(.*)"\]\.\s*$/;
57
+
58
+ function unwrapJsonEnvelope(line: string, warnings?: string[]): string {
59
+ const match = line.match(JSON_ENVELOPE_RE);
60
+ if (!match) return line;
61
+ const withoutDot = line.trim().slice(0, -1);
62
+ try {
63
+ JSON.parse(withoutDot);
64
+ return line;
65
+ } catch {
66
+ warnings?.push(
67
+ '[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
68
+ );
69
+ return match[1]!;
70
+ }
71
+ }
72
+
56
73
  export function parseText(edit: string[], warnings?: string[]): string[] {
57
74
  if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
58
75
  throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
@@ -60,13 +77,14 @@ export function parseText(edit: string[], warnings?: string[]): string[] {
60
77
  const out: string[] = [];
61
78
  let split = false;
62
79
  for (const line of edit) {
63
- const normalized = line.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
64
- if (normalized !== line) split = true;
80
+ const unwrapped = unwrapJsonEnvelope(line, warnings);
81
+ const normalized = unwrapped.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
82
+ if (normalized !== unwrapped) split = true;
65
83
  out.push(...normalized.split("\n"));
66
84
  }
67
85
  if (split) {
68
86
  warnings?.push(
69
- "[E_BAD_SHAPE] Autocorrected: split embedded newlines in replacement_lines into separate lines.",
87
+ "[E_BAD_SHAPE] replacement_lines contained embedded newlines; split into one line each.",
70
88
  );
71
89
  }
72
90
  return out;
@@ -195,7 +195,7 @@ export function stripAnchorRow(
195
195
  : match[1] === "-"
196
196
  ? 'leading "-" marker'
197
197
  : '"anchor│" prefix';
198
- warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
198
+ warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${clipLine(trimmed, 48)}".`);
199
199
  return match[2]!;
200
200
  }
201
201
 
@@ -240,13 +240,8 @@ export function stripBarePrefixes(
240
240
  const locations = stripped
241
241
  .map((s) => `replacement_lines line ${s.lineIndex + 1}`)
242
242
  .join(", ");
243
- const matchedCount = stripped.filter((s) => s.matched).length;
244
- const guidance =
245
- matchedCount === 0
246
- ? " Verify it was pasted from read output."
247
- : "";
248
243
  warnings.push(
249
- `[E_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.${guidance}`
244
+ `[E_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.`
250
245
  );
251
246
  return { ...edit, content_lines: contentLines };
252
247
  }
@@ -290,7 +285,7 @@ export function swapReversedRanges(
290
285
  return edit;
291
286
  }
292
287
  warnings.push(
293
- `[E_BAD_OP] Autocorrected: remove_from/remove_to were reversed; swapped them.`
288
+ `[E_BAD_OP] Swapped reversed remove_from/remove_to.`
294
289
  );
295
290
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
296
291
  }
@@ -100,7 +100,7 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
100
100
  : "The edit produced identical content.";
101
101
  const dedupNote =
102
102
  boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
103
- ? `\nBoundary dedup removed ${boundaryRemovedLines} line(s) from the replacement. Send the same edit again to apply it literally.`
103
+ ? `\nBoundary dedup removed ${boundaryRemovedLines} line(s); resend the same edit to apply it literally.`
104
104
  : "";
105
105
 
106
106
  const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${dedupNote}${warnBlock(warnings)}`;
@@ -146,7 +146,7 @@ export function regUndo(pi: ExtensionAPI): void {
146
146
  content: [
147
147
  {
148
148
  type: "text",
149
- text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file changed after the edit. The undo record is kept; once the file matches the edited state again, undo_last_change will succeed. Call read() to inspect the current state.`
149
+ text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file was modified after the edit, so nothing was reverted. The current content already contains your applied edit plus that external change and is most likely the correct state. Do not modify the file to make an undo possible and do not revert your own edit. The undo record is kept. Call read() to verify the current state, then stop.`
150
150
  },
151
151
  ],
152
152
  isError: true,
package/src/replace.ts CHANGED
@@ -63,6 +63,7 @@ export interface PipelineResult {
63
63
  totalRemovedLines: number;
64
64
  hadBoundaryDedup: boolean;
65
65
  boundaryRemovedLines: number;
66
+ boundaryRemovedLineTexts: string[];
66
67
  identity: FileIdentity;
67
68
  }
68
69
 
@@ -91,7 +92,7 @@ async function resolveMissingPath(
91
92
  if (matches.length === 1) {
92
93
  return {
93
94
  path: matches[0]!,
94
- warning: `[E_BAD_SHAPE] Autocorrected: missing "path" resolved to ${matches[0]}.`,
95
+ warning: `[E_BAD_SHAPE] Missing "path" resolved to ${matches[0]}.`,
95
96
  };
96
97
  }
97
98
  if (matches.length > 1) {
@@ -226,6 +227,7 @@ export async function execPipeline(
226
227
  totalRemovedLines,
227
228
  hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
228
229
  boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
230
+ boundaryRemovedLineTexts: anchorResult.autoFixes?.map((fix) => fix.removedLine) ?? [],
229
231
  identity,
230
232
  };
231
233
  }