pi-hashline-edit-pro 4.3.1 → 4.3.3

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
@@ -162,9 +162,12 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
162
162
 
163
163
  Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
164
164
 
165
- Files are filtered before injection: symlinks, directories, image extensions, binary files (a NUL byte in the first 8KB), files over 200KB, and any file whose name is in the built-in skip list (currently `package-lock.json`, matched by file name anywhere in the tree) are skipped. The attachment stops at 500 files or at a byte budget derived from the model's context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A file whose `read` output is truncated keeps its truncation hint, so the rest can be paged in with `read`.
165
+ Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand.
166
166
 
167
- The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`).
167
+ Each attached file is shown as `=== path ===` followed by its `anchor│content` rows edit directly from the attachment with replace and insert, no read needed. Files attach whole.
168
+ A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place — check that line instead of scanning sections.
169
+
170
+ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders are ignored via `/hashline-config` as Ignore folders and via `config.json` as `autoReadAllIgnore` (array of folder names, for example `["docs", "tmp"]`); a single-segment entry skips any path containing that folder (case-insensitive), while an entry with a slash (for example `"src/tmp"`) skips that folder path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
168
171
 
169
172
  ## Tool result details
170
173
 
@@ -181,7 +184,7 @@ All five tools return machine-readable metadata in `details` alongside the model
181
184
 
182
185
  | Command | Description |
183
186
  | --- | --- |
184
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
187
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
185
188
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
186
189
 
187
190
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -190,6 +193,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
190
193
  {
191
194
  "autoRead": true,
192
195
  "autoReadAll": "off",
196
+ "autoReadAllIgnore": [],
193
197
  "anchorGrepEnabled": true,
194
198
  "requirePath": false,
195
199
  "strictInput": false,
@@ -250,6 +254,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
250
254
  | `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
251
255
  | `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
252
256
  | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
257
+ | `[E_AUTO_READ_ALL]` | File already attached and unchanged since this session's start; the attached content is still exact. |
253
258
 
254
259
  ## Troubleshooting
255
260
 
package/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  toggleStrictInput,
22
22
  cycleBoundaryDedupMode,
23
23
  adjustDiffContextLines,
24
+ setAutoReadAllIgnoreFromText,
24
25
  } from "./src/config";
25
26
  import { loadHashStore, persistSnapshot, pruneMissing } from "./src/hash-store";
26
27
  import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
@@ -47,6 +48,7 @@ export default function (pi: ExtensionAPI): void {
47
48
 
48
49
  let autoRead = true;
49
50
  let autoReadAll: AutoReadAllMode = "off";
51
+ let autoReadAllIgnore: string[] = [];
50
52
  let autoReadAllInjected = false;
51
53
  let grepWasActive = false;
52
54
 
@@ -56,6 +58,7 @@ export default function (pi: ExtensionAPI): void {
56
58
  regRead(pi, flags);
57
59
  regReplace(pi, flags);
58
60
  regInsert(pi, flags);
61
+ regUndo(pi, flags);
59
62
  } catch (error) {
60
63
  console.error("Failed to refresh edit tools:", error);
61
64
  }
@@ -82,6 +85,7 @@ export default function (pi: ExtensionAPI): void {
82
85
  if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
83
86
  autoRead = config.autoRead;
84
87
  autoReadAll = config.autoReadAll ?? "off";
88
+ autoReadAllIgnore = config.autoReadAllIgnore ?? [];
85
89
  const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
86
90
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
87
91
  await refreshEditTools();
@@ -109,7 +113,7 @@ export default function (pi: ExtensionAPI): void {
109
113
  if (autoReadAll === "off" || autoReadAllInjected) return;
110
114
  autoReadAllInjected = true;
111
115
  try {
112
- const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll);
116
+ const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore);
113
117
  if (!injection) return;
114
118
  if (ctx.hasUI) ctx.ui.notify(`Auto-read all: attached ${injection.files} file(s) with anchors`, "info");
115
119
  return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
@@ -120,7 +124,7 @@ export default function (pi: ExtensionAPI): void {
120
124
  }));
121
125
 
122
126
  pi.registerCommand("hashline-config", {
123
- description: "Open the hashline settings window (auto-read, auto-read all, diff context, grep, path, strict input, dedup)",
127
+ description: "Open the hashline settings window (auto-read, auto-read all, ignore folders, diff context, grep, path, strict input, dedup)",
124
128
  handler: async (_args, ctx) => {
125
129
  if (!ctx.hasUI) {
126
130
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -131,9 +135,10 @@ export default function (pi: ExtensionAPI): void {
131
135
  tui,
132
136
  theme,
133
137
  done,
134
- onToggle: async (key, delta) => {
138
+ onToggle: async (key, delta, value) => {
135
139
  if (key === "autoRead") autoRead = await toggleAutoRead();
136
140
  else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
141
+ else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
137
142
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
138
143
  else if (key === "anchorGrepEnabled") {
139
144
  const enabled = await toggleAnchorGrep();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.3.1",
3
+ "version": "4.3.3",
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,3 +1,2 @@
1
1
  - `anchor_grep`: hits and `context` rows come back as `lineNumber │ anchor│content` — target them with `replace`/`insert` directly; `lineNumber` enables jump-to-line.
2
- - `anchor_grep`: `path` takes a file or folder (default cwd), `glob` like `*.ts` filters, `literal:true` for literal text, `context:N` for surrounding lines.
3
2
  - `anchor_grep`: binary/image files are skipped silently.
package/prompts/grep.md CHANGED
@@ -1 +1 @@
1
- Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or insert without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When the output says truncated, refine `pattern` or raise `limit` (default 100).
1
+ Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or insert without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
@@ -1,2 +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.
2
- - `insert`: a batch may pair one `before` and one `after` on the same anchor line; the pair composes into a single insertion. Any other same-line pair is an overlap.
1
+
package/prompts/insert.md CHANGED
@@ -1 +1 @@
1
- Insert lines after or before one existing line in a text file, addressed by a bare anchor from read output or a diff 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, no embedded newlines. Lines are added literally, even when they duplicate neighbors. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
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, no embedded newlines. Lines are added literally, even when they duplicate neighbors. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
@@ -1,3 +1,2 @@
1
- - `read`: view files with `read`, not `bash` (`sed`/`grep`/`cat`)only `read` rows carry usable anchors.
2
- - `read`: call before `replace` when you need fresh anchors.
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.
1
+ - `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.
2
+ - `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
@@ -1,5 +1,5 @@
1
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`).
3
- - `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed; never anchor on `-anchor│` rows, those anchors were freed by the edit. Check each batch diff before the next turn's edits on that file.
4
- - `replace`: batched calls must target disjoint ranges and all be valid; an overlap or any failure aborts the whole batch with nothing applied.
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).
2
+ - `replace`: `replacement_lines` `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically (single line: same anchor for `remove_from` and `remove_to`).
3
+ - `replace`: never anchor on `-anchor│` rows of the post-edit diff, those anchors were freed by the edit. Check each batch diff before the next turn's edits on that file.
4
+ - `replace`: same-file same-message batches must target disjoint ranges; an overlap or member failure aborts only that file's batch unwritten. Unresolvable calls stay solo and don't abort.
5
+ - `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically (not editable, never use `dedup` as an anchor).
@@ -1 +1 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from read output. 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 and no embedded newlines; `[]` deletes the range. Every line in the range must match what was last shown; if the file drifted, the edit is refused and the current range is returned with fresh anchors, so retry without re-reading. Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
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 and no embedded newlines; `[]` deletes the range. Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
@@ -0,0 +1,13 @@
1
+ const completeSnapshots = new Map<string, string>();
2
+ export function recordAutoReadAllComplete(absolutePath: string, snapshotId: string): void {
3
+ completeSnapshots.set(absolutePath, snapshotId);
4
+ }
5
+ export function getAutoReadAllSnapshot(absolutePath: string): string | undefined {
6
+ return completeSnapshots.get(absolutePath);
7
+ }
8
+ export function invalidateAutoReadAllComplete(absolutePath: string): void {
9
+ completeSnapshots.delete(absolutePath);
10
+ }
11
+ export function clearAutoReadAllComplete(): void {
12
+ completeSnapshots.clear();
13
+ }
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { lstat, open, readdir } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent";
4
+ import { formatSize } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  AUTO_READ_ALL_MAX_BUDGET_BYTES,
7
7
  AUTO_READ_ALL_MAX_FILE_BYTES,
@@ -9,22 +9,24 @@ import {
9
9
  AUTO_READ_ALL_MIN_BUDGET_BYTES,
10
10
  SNIFF_BYTES,
11
11
  } from "./constants";
12
- import type { AutoReadAllMode } from "./config";
12
+ import { normalizeAutoReadAllIgnoreEntry, type AutoReadAllMode } from "./config";
13
13
  import { serveRows } from "./served";
14
- import { readNormFile } from "./file-reader";
14
+ import { readNormFile, safeSnapId } from "./file-reader";
15
15
  import { resolveRgPath } from "./grep";
16
16
  import { MAX_HASH_LINES } from "./hashline";
17
17
  import { fmtReadPreview } from "./read";
18
18
  import { splitLines } from "./utils";
19
+ import { recordAutoReadAllComplete } from "./auto-read-all-state";
19
20
 
20
21
  const EXEC_TIMEOUT_MS = 20_000;
21
22
  const EXEC_MAX_BYTES = 64 * 1024 * 1024;
22
23
  const SCAN_CONCURRENCY = 32;
23
24
  const SCAN_LIMIT_MULTIPLIER = 4;
24
25
  const MAX_REPORTED_OMISSIONS = 50;
26
+ export const AUTO_READ_ALL_CHUNK_BYTES = 48 * 1024;
25
27
 
26
28
  const HEADER =
27
- "[hashline auto-read-all] The content of every non-ignored project file is attached below with live hashline anchors. Each anchor│content row is already owned and served for this session, so replace and insert can target those anchors directly without calling read first. Rows with a truncation hint are only partially shown; call read with the hinted offset to see the rest.";
29
+ "[hashline auto-read-all] Every non-ignored project file is attached below with live hashline anchors. Do not call read for files in [files complete: ...]. Edit directly from the attachment with replace and insert.";
28
30
 
29
31
  const IMAGE_EXTENSIONS = new Set([
30
32
  ".avif",
@@ -38,14 +40,95 @@ const IMAGE_EXTENSIONS = new Set([
38
40
  ".jxl",
39
41
  ".png",
40
42
  ".psd",
43
+ ".svg",
41
44
  ".tif",
42
45
  ".tiff",
43
46
  ".webp",
44
47
  ]);
45
48
 
46
- export const AUTO_READ_ALL_EXCLUDED_NAMES = ["package-lock.json"];
47
-
49
+ export const AUTO_READ_ALL_EXCLUDED_SEGMENTS = [
50
+ "vendor",
51
+ "node_modules",
52
+ "bower_components",
53
+ "third_party",
54
+ "thirdparty",
55
+ "jspm_packages",
56
+ ".venv",
57
+ "venv",
58
+ "site-packages",
59
+ "__pycache__",
60
+ ".tox",
61
+ ".gradle",
62
+ ".terraform",
63
+ "pods",
64
+ "carthage",
65
+ "deriveddata",
66
+ "coreui",
67
+ "coreui-icons",
68
+ ];
69
+ export const AUTO_READ_ALL_EXCLUDED_NAMES = [
70
+ "package-lock.json",
71
+ "yarn.lock",
72
+ "composer.lock",
73
+ "gemfile.lock",
74
+ "cargo.lock",
75
+ "poetry.lock",
76
+ "pipfile.lock",
77
+ "go.sum",
78
+ "flake.lock",
79
+ ".eslintcache",
80
+ ];
48
81
  const EXCLUDED_NAME_SET = new Set(AUTO_READ_ALL_EXCLUDED_NAMES);
82
+ const EXCLUDED_SEGMENT_SET = new Set(AUTO_READ_ALL_EXCLUDED_SEGMENTS);
83
+ function isExcludedBySegment(path: string): boolean {
84
+ for (const segment of path.split("/")) {
85
+ if (EXCLUDED_SEGMENT_SET.has(segment.toLowerCase())) return true;
86
+ }
87
+ return false;
88
+ }
89
+ function isExcludedByPattern(baseLower: string): boolean {
90
+ if (baseLower.endsWith(".min.js") || baseLower.endsWith(".min.css") || baseLower.endsWith(".min.mjs")) return true;
91
+ if (baseLower.endsWith("-min.js") || baseLower.endsWith("-min.css")) return true;
92
+ if (baseLower.includes(".bundle.") || baseLower.includes(".chunk.")) return true;
93
+ if (baseLower.endsWith(".umd.js")) return true;
94
+ if (baseLower.endsWith(".map")) return true;
95
+ if (baseLower.endsWith(".lock")) return true;
96
+ if (baseLower.includes(".generated.") || baseLower.includes(".gen.")) return true;
97
+ if (baseLower.endsWith("_pb2.py")) return true;
98
+ if (baseLower.endsWith(".pb.go")) return true;
99
+ if (baseLower.endsWith(".g.dart")) return true;
100
+ if (baseLower.endsWith(".freezed.dart")) return true;
101
+ if (baseLower.endsWith(".designer.cs")) return true;
102
+ if (baseLower.endsWith(".g.cs")) return true;
103
+ if (baseLower.endsWith(".snap")) return true;
104
+ if (baseLower.startsWith("coreui-icons.")) return true;
105
+ if (baseLower === "coreui.css") return true;
106
+ return false;
107
+ }
108
+ export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | undefined): string[] {
109
+ const seen = new Set<string>();
110
+ const out: string[] = [];
111
+ for (const entry of entries ?? []) {
112
+ if (typeof entry !== "string") continue;
113
+ const cleaned = normalizeAutoReadAllIgnoreEntry(entry).toLowerCase();
114
+ if (cleaned.length === 0 || seen.has(cleaned)) continue;
115
+ seen.add(cleaned);
116
+ out.push(cleaned);
117
+ }
118
+ return out;
119
+ }
120
+ export function isExcludedByCustomIgnore(path: string, customIgnore: readonly string[]): boolean {
121
+ if (customIgnore.length === 0) return false;
122
+ const lower = path.toLowerCase();
123
+ const segments = lower.split("/");
124
+ for (const entry of customIgnore) {
125
+ if (entry.length === 0) continue;
126
+ if (!entry.includes("/")) {
127
+ if (segments.includes(entry)) return true;
128
+ } else if (lower === entry || lower.startsWith(entry + "/") || lower.includes("/" + entry + "/") || lower.endsWith("/" + entry)) return true;
129
+ }
130
+ return false;
131
+ }
49
132
 
50
133
  const WALK_IGNORED_DIRS = new Set([
51
134
  ".git",
@@ -79,11 +162,19 @@ export interface AutoReadAllDiscovery {
79
162
  skippedByName: number;
80
163
  }
81
164
 
165
+ export interface AutoReadAllSection {
166
+ file: string;
167
+ text: string;
168
+ totalLines: number;
169
+ absolutePath: string;
170
+ }
82
171
  export interface AutoReadAllInjection {
83
172
  text: string;
84
173
  files: number;
85
174
  bytes: number;
86
175
  omitted: string[];
176
+ chunks: string[];
177
+ completeFiles: number;
87
178
  }
88
179
 
89
180
  function runCommand(command: string, args: string[], cwd: string): Promise<{ stdout: string; code: number }> {
@@ -118,7 +209,7 @@ async function listFromRg(cwd: string): Promise<string[] | undefined> {
118
209
  return result.stdout.split("\0").filter((entry) => entry.length > 0);
119
210
  }
120
211
 
121
- async function walkDir(dir: string, base: string, out: string[]): Promise<void> {
212
+ async function walkDir(dir: string, base: string, out: string[], customIgnore: readonly string[] = []): Promise<void> {
122
213
  let entries;
123
214
  try {
124
215
  entries = await readdir(dir, { withFileTypes: true });
@@ -129,8 +220,9 @@ async function walkDir(dir: string, base: string, out: string[]): Promise<void>
129
220
  if (entry.isSymbolicLink()) continue;
130
221
  const full = join(dir, entry.name);
131
222
  if (entry.isDirectory()) {
132
- if (WALK_IGNORED_DIRS.has(entry.name)) continue;
133
- await walkDir(full, base, out);
223
+ if (WALK_IGNORED_DIRS.has(entry.name) || EXCLUDED_SEGMENT_SET.has(entry.name.toLowerCase())) continue;
224
+ if (customIgnore.length > 0 && isExcludedByCustomIgnore(toPosix(relative(base, full)), customIgnore)) continue;
225
+ await walkDir(full, base, out, customIgnore);
134
226
  } else if (entry.isFile()) {
135
227
  out.push(toPosix(relative(base, full)));
136
228
  }
@@ -181,7 +273,8 @@ async function forEachLimit<T>(items: T[], limit: number, work: (item: T) => Pro
181
273
  await Promise.all(workers);
182
274
  }
183
275
 
184
- export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMode = "on"): Promise<AutoReadAllDiscovery> {
276
+ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = []): Promise<AutoReadAllDiscovery> {
277
+ const customIgnore = normalizeAutoReadAllIgnoreList(ignoreDirs);
185
278
  let source: AutoReadAllSource = "git";
186
279
  let candidates = await listFromGit(cwd);
187
280
  if (candidates === undefined) {
@@ -194,7 +287,7 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
194
287
  if (candidates === undefined) {
195
288
  source = "walk";
196
289
  const walked: string[] = [];
197
- await walkDir(cwd, cwd, walked);
290
+ await walkDir(cwd, cwd, walked, customIgnore);
198
291
  candidates = walked;
199
292
  }
200
293
 
@@ -202,7 +295,8 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
202
295
  const includable: string[] = [];
203
296
  let skippedByName = 0;
204
297
  for (const file of unique) {
205
- if (EXCLUDED_NAME_SET.has(baseNameOf(file).toLowerCase())) skippedByName += 1;
298
+ const baseLower = baseNameOf(file).toLowerCase();
299
+ if (EXCLUDED_NAME_SET.has(baseLower) || isExcludedBySegment(file) || isExcludedByPattern(baseLower) || isExcludedByCustomIgnore(file, customIgnore)) skippedByName += 1;
206
300
  else includable.push(file);
207
301
  }
208
302
  const scanWindow = includable.slice(0, AUTO_READ_ALL_MAX_FILES * SCAN_LIMIT_MULTIPLIER);
@@ -247,12 +341,30 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
247
341
  return { files, source, discovered: unique.length, skippedBinary, skippedLarge, skippedOther, skippedByName };
248
342
  }
249
343
 
250
- async function renderFile(file: string, cwd: string): Promise<string | undefined> {
344
+ export function chunkAutoReadAllSections(sections: string[], maxBytes: number = AUTO_READ_ALL_CHUNK_BYTES): string[] {
345
+ const chunks: string[] = [];
346
+ let current: string[] = [];
347
+ let currentBytes = 0;
348
+ for (const section of sections) {
349
+ const sectionBytes = Buffer.byteLength(section, "utf-8") + 2;
350
+ if (current.length > 0 && currentBytes + sectionBytes > maxBytes) {
351
+ chunks.push(current.join("\n\n"));
352
+ current = [];
353
+ currentBytes = 0;
354
+ }
355
+ current.push(section);
356
+ currentBytes += sectionBytes;
357
+ }
358
+ if (current.length > 0) chunks.push(current.join("\n\n"));
359
+ return chunks;
360
+ }
361
+ async function renderFile(file: string, cwd: string): Promise<AutoReadAllSection | undefined> {
251
362
  try {
252
363
  const { normalized, fileHashes, absolutePath } = await readNormFile(file, cwd, { maxLines: MAX_HASH_LINES });
253
- const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES);
364
+ const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath, AUTO_READ_ALL_MAX_BUDGET_BYTES, MAX_HASH_LINES);
254
365
  serveRows(absolutePath, fileHashes, splitLines(normalized), preview.servedHashes);
255
- return `=== ${file} ===\n${preview.text}`;
366
+ const totalLines = fileHashes.length;
367
+ return { file, text: `=== ${file} ===\n${preview.text}`, totalLines, absolutePath };
256
368
  } catch (error) {
257
369
  console.error(`Auto-read all: skipped ${file}:`, error);
258
370
  return undefined;
@@ -269,7 +381,7 @@ function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted:
269
381
  if (discovery.skippedBinary > 0) notes.push(`${discovery.skippedBinary} binary or image file(s) skipped`);
270
382
  if (discovery.skippedLarge > 0) notes.push(`${discovery.skippedLarge} file(s) over ${formatSize(AUTO_READ_ALL_MAX_FILE_BYTES)} skipped`);
271
383
  if (discovery.skippedOther > 0) notes.push(`${discovery.skippedOther} unreadable path(s) skipped`);
272
- if (discovery.skippedByName > 0) notes.push(`${discovery.skippedByName} file(s) skipped by name (${AUTO_READ_ALL_EXCLUDED_NAMES.join(", ")})`);
384
+ if (discovery.skippedByName > 0) notes.push(`${discovery.skippedByName} file(s) skipped by vendor/name/pattern rules`);
273
385
  const listed = omitted.slice(0, MAX_REPORTED_OMISSIONS).join(", ");
274
386
  const more = omitted.length > MAX_REPORTED_OMISSIONS ? `, ... (+${omitted.length - MAX_REPORTED_OMISSIONS} more)` : "";
275
387
  const omissionNote = omitted.length > 0 ? ` Not attached: ${listed}${more}. Use read for those.` : "";
@@ -277,29 +389,38 @@ function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted:
277
389
  return `[hashline auto-read-all: ${attached} file(s) attached from ${discovery.source}; ${summary}${omissionNote}]`;
278
390
  }
279
391
 
280
- export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on"): Promise<AutoReadAllInjection | undefined> {
281
- const discovery = await discoverAutoReadAllFiles(cwd, mode);
392
+ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = []): Promise<AutoReadAllInjection | undefined> {
393
+ const discovery = await discoverAutoReadAllFiles(cwd, mode, ignoreDirs);
282
394
  if (discovery.files.length === 0) return undefined;
283
- const sections: string[] = [];
395
+ const sections: AutoReadAllSection[] = [];
284
396
  const omitted: string[] = [];
285
397
  let bytes = 0;
398
+ let completeFiles = 0;
286
399
  for (const file of discovery.files) {
287
400
  const section = await renderFile(file, cwd);
288
401
  if (section === undefined) {
289
402
  omitted.push(file);
290
403
  continue;
291
404
  }
292
- const sectionBytes = Buffer.byteLength(section, "utf-8") + 1;
405
+ const sectionBytes = Buffer.byteLength(section.text, "utf-8") + 1;
293
406
  if (sections.length > 0 && bytes + sectionBytes > budgetBytes) {
294
407
  omitted.push(file);
295
408
  continue;
296
409
  }
297
410
  sections.push(section);
411
+ const snapshotId = await safeSnapId(section.absolutePath, "auto-read-all");
412
+ if (snapshotId !== undefined) recordAutoReadAllComplete(section.absolutePath, snapshotId);
298
413
  bytes += sectionBytes;
414
+ completeFiles += 1;
299
415
  }
300
416
  if (sections.length === 0) return undefined;
301
- const text = `${HEADER}\n\n${sections.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
302
- return { text, files: sections.length, bytes, omitted };
417
+ const sectionTexts = sections.map((section) => section.text);
418
+ const chunks = chunkAutoReadAllSections(sectionTexts);
419
+ const coverage = `[coverage: ${completeFiles} complete]`;
420
+ const completeNames = sections.map((section) => section.file);
421
+ const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
422
+ const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
423
+ return { text, files: sections.length, bytes, omitted, chunks, completeFiles };
303
424
  }
304
425
 
305
426
  export function autoReadAllBudget(model: { contextWindow?: number } | undefined): number {
package/src/batch.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
- import { relative } from "node:path";
3
+ import { toDisplayPath } from "./paths";
4
4
  import { readConfig, getDiffContextLines } from "./config";
5
5
  import { throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
6
6
  import { readNormFile, safeSnapId } from "./file-reader";
@@ -178,39 +178,17 @@ function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
178
178
  }
179
179
  return undefined;
180
180
  }
181
- function anchorTargetFor(args: unknown): string | undefined {
182
- const normalized = normalizeEditArgs(args);
183
- if (!normalized) return undefined;
184
- if (normalized.kind === "replace") return tryResolveEditTarget(normalized.removeFrom, normalized.removeTo);
185
- return tryResolveEditTarget(normalized.anchor);
186
- }
187
- async function inferredTargetFor(args: unknown, cwd: string, requirePath: boolean): Promise<string | undefined> {
188
- const normalized = normalizeEditArgs(args);
189
- if (!normalized) return undefined;
190
- if (requirePath && normalized.path) {
191
- try {
192
- return (await resolveInCwd(normalized.path, cwd)).resolved;
193
- } catch {
194
- return undefined;
195
- }
196
- }
197
- if (normalized.kind === "replace") {
198
- return tryResolveEditTarget(normalized.removeFrom) ?? (normalized.removeTo ? tryResolveEditTarget(normalized.removeTo) : undefined);
199
- }
200
- return undefined;
201
- }
202
181
 
203
182
  async function verifyPaths(
204
- group: Array<{ id: string; target: string; kind: BatchKind; args: unknown }>,
183
+ group: Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }>,
205
184
  cwd: string,
206
- ): Promise<Array<{ id: string; target: string; kind: BatchKind; args: unknown }>> {
207
- const verified: Array<{ id: string; target: string; kind: BatchKind; args: unknown }> = [];
185
+ ): Promise<Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }>> {
186
+ const verified: Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }> = [];
208
187
  for (const item of group) {
209
- const normalized = normalizeEditArgs(item.args);
210
- if (!normalized || !normalized.path) continue;
188
+ if (!item.path) continue;
211
189
  let resolved: string | undefined;
212
190
  try {
213
- resolved = (await resolveInCwd(normalized.path, cwd)).resolved;
191
+ resolved = (await resolveInCwd(item.path, cwd)).resolved;
214
192
  } catch {
215
193
  resolved = undefined;
216
194
  }
@@ -241,11 +219,24 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
241
219
  if (calls.length < 2) return;
242
220
  const earlyConfig = await readConfig();
243
221
  const requirePath = earlyConfig.requirePath === true;
244
- interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown }
222
+ interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown; path?: string }
245
223
  const resolved: ResolvedCall[] = [];
246
224
  for (const call of calls) {
247
- const target = anchorTargetFor(call.args) ?? await inferredTargetFor(call.args, cwd, requirePath);
248
- if (target) resolved.push({ id: call.id, target, kind: call.name as BatchKind, args: call.args });
225
+ const normalized = normalizeEditArgs(call.args);
226
+ if (!normalized) continue;
227
+ let target = normalized.kind === "replace" ? tryResolveEditTarget(normalized.removeFrom, normalized.removeTo) : tryResolveEditTarget(normalized.anchor);
228
+ if (!target) {
229
+ if (requirePath && normalized.path) {
230
+ try {
231
+ target = (await resolveInCwd(normalized.path, cwd)).resolved;
232
+ } catch {
233
+ target = undefined;
234
+ }
235
+ } else if (normalized.kind === "replace") {
236
+ target = tryResolveEditTarget(normalized.removeFrom) ?? (normalized.removeTo ? tryResolveEditTarget(normalized.removeTo) : undefined);
237
+ }
238
+ }
239
+ if (target) resolved.push({ id: call.id, target, kind: call.name as BatchKind, args: call.args, ...(normalized.path ? { path: normalized.path } : {}) });
249
240
  }
250
241
  const groups = new Map<string, ResolvedCall[]>();
251
242
  for (const item of resolved) {
@@ -447,7 +438,7 @@ export async function ensureBatchBase(input: {
447
438
  runtime.paths = {
448
439
  absolutePath: file.absolutePath,
449
440
  mutationTargetPath: input.mutationTargetPath,
450
- displayPath: relative(input.cwd, file.absolutePath).replace(/\\/g, "/") || input.targetPath,
441
+ displayPath: toDisplayPath(input.cwd, file.absolutePath, input.targetPath),
451
442
  };
452
443
  return base;
453
444
  }