pi-hashline-edit-pro 6.4.3 → 6.4.5

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
@@ -159,7 +159,7 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
159
159
 
160
160
  ### read
161
161
 
162
- `read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return after it. Anchor offsets are stable across edits and return the same editable rows.
162
+ `read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return starting at that anchor row. Anchor offsets are stable across edits and return the same editable rows.
163
163
 
164
164
  | Parameter | Description |
165
165
  | --- | --- |
@@ -207,7 +207,7 @@ A deletion keeps blank lines at the edges of the removed range, so the separator
207
207
 
208
208
  The extension checks the request before any file I/O, so a bad request never touches the file.
209
209
 
210
- Auto-fixable slips fall into two groups. Fixed silently: a reversed range, embedded newlines, and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning: a leftover `anchor│` prefix in `text` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement.
210
+ Auto-fixable slips fall into two groups. Fixed silently: embedded newlines and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning or a hint: a reversed `remove_from`/`remove_to` range is applied in document order with an `[H_RANGE_REVERSED]` hint; a leftover `anchor│` prefix in `text` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`) and diff-preview rows pasted into the replacement are stripped with a warning.
211
211
 
212
212
  Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to the `text` field of both `replace` and `insert`.
213
213
 
@@ -341,7 +341,7 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
341
341
 
342
342
  Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing; `outline` attaches an anchor-stamped structural outline per file; and `full` 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 attaches full contents. `Git repos only` is on by default and keeps the whole injection inside a git repository; turn it off to attach files in any directory. 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.
343
343
 
344
- 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; `resources/views/vendor` is kept so Laravel view overrides stay attached), 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, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .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; `full` also caps the budget at 2MB, while `outline` follows the context-derived budget because outlines stay compact. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
344
+ 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; `resources/views/vendor` is kept so Laravel view overrides stay attached), 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, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .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; `full` also caps the budget at 2MB, while `outline` follows the context-derived budget because outlines stay compact. Files are ranked before the budget applies: working-tree changes first, then manifest and entry files, then the dominant source tree, then everything else, and no single top-level directory may take more than 40% of the budget in the first pass, so one large subtree cannot crowd out the rest. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
345
345
 
346
346
  Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget. In outline mode, multi-line symbols appear as labels with `[limit N]` and single-line symbols appear as their content.
347
347
 
@@ -473,6 +473,7 @@ Full reference:
473
473
  | `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
474
474
  | `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
475
475
  | `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between Aaaa│ and Dddd│.` |
476
+ | `[H_RANGE_REVERSED]` | The `remove_from` anchor resolved to a line after `remove_to`. The call applied the range in document order; the one-line hint names the line range used. |
476
477
  | `[E_NOT_FOUND]` | The path does not exist. |
477
478
  | `[E_ACCESS]` | The file is not readable or writable. |
478
479
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
package/index.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  setDisableOnModelsFromText,
34
34
  } from "./src/config";
35
35
  import { loadHashStore, pruneMissing } from "./src/hash-store";
36
- import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
36
+ import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
37
37
  import { serveRows } from "./src/served";
38
38
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
39
39
  import { currentEditFlags } from "./src/edit-common";
@@ -46,6 +46,11 @@ import { valAccess } from "./src/validation";
46
46
  import { splitLines } from "./src/utils";
47
47
  import { AUTO_READ_ALL_CUSTOM_TYPE } from "./src/constants";
48
48
 
49
+ interface PendingRegistrySession {
50
+ key: string | undefined;
51
+ alive: () => boolean;
52
+ }
53
+
49
54
  export default function (pi: ExtensionAPI): void {
50
55
  regRead(pi);
51
56
 
@@ -72,6 +77,7 @@ export default function (pi: ExtensionAPI): void {
72
77
  let grepFlavorCwd: string | undefined;
73
78
  const baseAnchorTools = new Set<string>();
74
79
  let gateApplied = false;
80
+ const pendingRegistrySessions: PendingRegistrySession[] = [];
75
81
 
76
82
  async function refreshEditTools(): Promise<void> {
77
83
  try {
@@ -144,7 +150,36 @@ export default function (pi: ExtensionAPI): void {
144
150
  pi.setActiveTools([...new Set([...base, ...enabled])]);
145
151
  }
146
152
 
147
- pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
153
+ function captureRegistrySession(ctx: AnchorSessionContext | undefined): void {
154
+ const key = sessionKeyFor(ctx);
155
+ const entry: PendingRegistrySession = {
156
+ key,
157
+ alive: () => {
158
+ try {
159
+ void ctx?.sessionManager;
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ },
165
+ };
166
+ const index = key === undefined ? -1 : pendingRegistrySessions.findIndex((candidate) => candidate.key === key);
167
+ if (index >= 0) pendingRegistrySessions[index] = entry;
168
+ else pendingRegistrySessions.push(entry);
169
+ }
170
+
171
+ function releaseCapturedRegistrySession(key: string): void {
172
+ const index = pendingRegistrySessions.findIndex((entry) => entry.key === key);
173
+ if (index >= 0) pendingRegistrySessions.splice(index, 1);
174
+ releaseRegistrySession(key);
175
+ }
176
+
177
+ async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
178
+ captureRegistrySession(ctx);
179
+ return withAnchorSession(ctx, fn);
180
+ }
181
+
182
+ pi.on("session_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
148
183
  const active = pi.getActiveTools();
149
184
  baseAnchorTools.clear();
150
185
  for (const tool of ANCHOR_TOOL_NAMES) {
@@ -193,17 +228,36 @@ export default function (pi: ExtensionAPI): void {
193
228
 
194
229
  pi.on("session_shutdown", async (_event, ctx) => {
195
230
  try {
196
- const key = sessionKeyFor(ctx);
197
- if (key !== undefined) releaseRegistrySession(key);
231
+ let key: string | undefined;
232
+ let readable = true;
233
+ try {
234
+ key = sessionKeyFor(ctx);
235
+ } catch {
236
+ readable = false;
237
+ }
238
+ if (readable && key !== undefined) {
239
+ releaseCapturedRegistrySession(key);
240
+ return;
241
+ }
242
+ if (readable) {
243
+ const dead = pendingRegistrySessions.findIndex((entry) => entry.key === undefined && !entry.alive());
244
+ const fallback = pendingRegistrySessions.findIndex((entry) => entry.key === undefined);
245
+ const index = dead >= 0 ? dead : fallback;
246
+ if (index >= 0) pendingRegistrySessions.splice(index, 1);
247
+ return;
248
+ }
249
+ const dead = pendingRegistrySessions.findIndex((entry) => !entry.alive());
250
+ const entry = dead >= 0 ? pendingRegistrySessions.splice(dead, 1)[0] : pendingRegistrySessions.shift();
251
+ if (entry?.key !== undefined) releaseRegistrySession(entry.key);
198
252
  } catch (error) {
199
253
  console.error("Failed to release anchor registry session:", error);
200
254
  }
201
255
  });
202
- pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
256
+ pi.on("model_select", async (event, ctx) => withRegistrySession(ctx, async () => {
203
257
  await syncModelGate(event.model, ctx.cwd);
204
258
  }));
205
259
 
206
- pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
260
+ pi.on("before_agent_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
207
261
  await syncModelGate(ctx.model, ctx.cwd);
208
262
  if (autoReadAll === "off" || autoReadAllInjected) return;
209
263
  if (modelDisabled(ctx.model, disableOnModels)) return;
@@ -281,12 +335,12 @@ export default function (pi: ExtensionAPI): void {
281
335
 
282
336
  pi.registerCommand("clear-anchors", {
283
337
  description: "Clear the session's anchor claims (path-free resolution state); anchors are re-claimed on the next read",
284
- handler: async (_args, ctx) => withAnchorSession(ctx, async () => {
338
+ handler: async (_args, ctx) => withRegistrySession(ctx, async () => {
285
339
  clearRegistry();
286
340
  ctx.ui.notify(`Anchor claims cleared for this session`, "info");
287
341
  }),
288
342
  });
289
- pi.on("message_end", async (event, ctx) => withAnchorSession(ctx, async () => {
343
+ pi.on("message_end", async (event, ctx) => withRegistrySession(ctx, async () => {
290
344
  try {
291
345
  await planAssistantMessage(event.message, ctx.cwd);
292
346
  } catch (error) {
@@ -301,7 +355,7 @@ export default function (pi: ExtensionAPI): void {
301
355
  console.error("Failed to finalize edit batch:", error);
302
356
  }
303
357
  });
304
- pi.on("tool_result", async (event, ctx) => withAnchorSession(ctx, async () => {
358
+ pi.on("tool_result", async (event, ctx) => withRegistrySession(ctx, async () => {
305
359
  if (event.isError) return;
306
360
  const gated = modelDisabled(ctx.model, disableOnModels);
307
361
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.3",
3
+ "version": "6.4.5",
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",
@@ -3,6 +3,7 @@ import { lstat, open, readdir } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
4
  import { formatSize } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
+ AUTO_READ_ALL_DIR_BUDGET_SHARE,
6
7
  AUTO_READ_ALL_MAX_BUDGET_BYTES,
7
8
  AUTO_READ_ALL_MAX_FILE_BYTES,
8
9
  AUTO_READ_ALL_MAX_FILES,
@@ -108,6 +109,137 @@ function isExcludedByPattern(baseLower: string): boolean {
108
109
  || baseLower.startsWith("coreui-icons.")
109
110
  || baseLower === "coreui.css";
110
111
  }
112
+
113
+ const MANIFEST_BASE_NAMES = new Set([
114
+ "package.json",
115
+ "tsconfig.json",
116
+ "jsconfig.json",
117
+ "pyproject.toml",
118
+ "setup.py",
119
+ "setup.cfg",
120
+ "requirements.txt",
121
+ "pipfile",
122
+ "cargo.toml",
123
+ "go.mod",
124
+ "gemfile",
125
+ "rakefile",
126
+ "makefile",
127
+ "gnumakefile",
128
+ "cmakelists.txt",
129
+ "meson.build",
130
+ "build.gradle",
131
+ "build.gradle.kts",
132
+ "settings.gradle",
133
+ "settings.gradle.kts",
134
+ "pom.xml",
135
+ "mix.exs",
136
+ "composer.json",
137
+ "dockerfile",
138
+ "docker-compose.yml",
139
+ "docker-compose.yaml",
140
+ "compose.yml",
141
+ "compose.yaml",
142
+ "readme.md",
143
+ "readme.rst",
144
+ "readme.txt",
145
+ "readme",
146
+ ]);
147
+
148
+ const SOURCE_TOP_LEVEL_NAMES = new Set([
149
+ "src",
150
+ "source",
151
+ "lib",
152
+ "libs",
153
+ "app",
154
+ "apps",
155
+ "packages",
156
+ "server",
157
+ "client",
158
+ "web",
159
+ "api",
160
+ "cmd",
161
+ "internal",
162
+ "pkg",
163
+ "core",
164
+ "modules",
165
+ "components",
166
+ "services",
167
+ "plugins",
168
+ "include",
169
+ ]);
170
+
171
+ const NON_SOURCE_TOP_LEVEL_NAMES = new Set([
172
+ "test",
173
+ "tests",
174
+ "spec",
175
+ "specs",
176
+ "__tests__",
177
+ "__mocks__",
178
+ "docs",
179
+ "doc",
180
+ "documentation",
181
+ "examples",
182
+ "example",
183
+ "samples",
184
+ "sample",
185
+ "fixtures",
186
+ "benchmark",
187
+ "benchmarks",
188
+ "bench",
189
+ "e2e",
190
+ "scripts",
191
+ "assets",
192
+ "static",
193
+ "public",
194
+ "coverage",
195
+ "migrations",
196
+ "locales",
197
+ "i18n",
198
+ "styles",
199
+ ]);
200
+
201
+ function topSegmentOf(path: string): string | undefined {
202
+ const index = path.indexOf("/");
203
+ return index < 0 ? undefined : path.slice(0, index);
204
+ }
205
+
206
+ function detectSourceTopLevel(files: readonly string[]): Set<string> {
207
+ const counts = new Map<string, number>();
208
+ for (const file of files) {
209
+ const top = topSegmentOf(file)?.toLowerCase();
210
+ if (top === undefined || NON_SOURCE_TOP_LEVEL_NAMES.has(top)) continue;
211
+ counts.set(top, (counts.get(top) ?? 0) + 1);
212
+ }
213
+ let dominant: string | undefined;
214
+ let dominantCount = 0;
215
+ for (const [name, count] of counts) {
216
+ if (count > dominantCount) {
217
+ dominant = name;
218
+ dominantCount = count;
219
+ }
220
+ }
221
+ const source = new Set(SOURCE_TOP_LEVEL_NAMES);
222
+ if (dominant !== undefined) source.add(dominant);
223
+ return source;
224
+ }
225
+
226
+ function priorityOf(file: string, changed: ReadonlySet<string>, sourceTopLevel: ReadonlySet<string>): number {
227
+ if (changed.has(file)) return 0;
228
+ if (MANIFEST_BASE_NAMES.has(baseNameOf(file).toLowerCase())) return 1;
229
+ const top = topSegmentOf(file)?.toLowerCase();
230
+ if (top !== undefined && sourceTopLevel.has(top)) return 2;
231
+ return 3;
232
+ }
233
+
234
+ function rankFiles(files: readonly string[], changed: ReadonlySet<string>, sourceTopLevel: ReadonlySet<string>): string[] {
235
+ return [...files].sort((a, b) => {
236
+ const byPriority = priorityOf(a, changed, sourceTopLevel) - priorityOf(b, changed, sourceTopLevel);
237
+ if (byPriority !== 0) return byPriority;
238
+ if (a === b) return 0;
239
+ return a < b ? -1 : 1;
240
+ });
241
+ }
242
+
111
243
  export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | undefined): string[] {
112
244
  const seen = new Set<string>();
113
245
  const out: string[] = [];
@@ -228,6 +360,36 @@ async function listFromGit(cwd: string): Promise<string[] | undefined> {
228
360
  return result.stdout.split("\0").filter((entry) => entry.length > 0);
229
361
  }
230
362
 
363
+ async function listChangedFiles(cwd: string): Promise<Set<string>> {
364
+ const changed = new Set<string>();
365
+ const prefixResult = await runCommand("git", ["rev-parse", "--show-prefix"], cwd);
366
+ const prefix = prefixResult.code === 0 ? toPosix(prefixResult.stdout.trim()) : "";
367
+ const result = await runCommand("git", ["status", "--porcelain", "-z", "-uall"], cwd);
368
+ if (result.code !== 0) return changed;
369
+ const fields = result.stdout.split("\0");
370
+ for (let index = 0; index < fields.length; index += 1) {
371
+ const field = fields[index];
372
+ if (field === undefined || field.length < 4) continue;
373
+ const status = field.slice(0, 2);
374
+ addChangedPath(changed, field.slice(3), prefix);
375
+ if (status.includes("R") || status.includes("C")) {
376
+ index += 1;
377
+ const source = fields[index];
378
+ if (source !== undefined && source.length > 0) addChangedPath(changed, source, prefix);
379
+ }
380
+ }
381
+ return changed;
382
+ }
383
+
384
+ function addChangedPath(changed: Set<string>, path: string, prefix: string): void {
385
+ const posix = toPosix(path);
386
+ if (prefix.length > 0 && posix.startsWith(prefix)) {
387
+ changed.add(posix.slice(prefix.length));
388
+ return;
389
+ }
390
+ changed.add(posix);
391
+ }
392
+
231
393
  async function listFromRg(cwd: string): Promise<string[] | undefined> {
232
394
  let rgPath: string;
233
395
  try {
@@ -331,16 +493,19 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
331
493
  const unique = [...new Set(candidates.map(toPosix))].sort();
332
494
  const includable: string[] = [];
333
495
  let skippedByName = 0;
496
+ let skippedBinary = 0;
497
+ let skippedLarge = 0;
498
+ let skippedOther = 0;
334
499
  for (const file of unique) {
335
500
  const baseLower = baseNameOf(file).toLowerCase();
336
501
  if (EXCLUDED_NAME_SET.has(baseLower) || isExcludedBySegment(file) || isExcludedByPattern(baseLower) || isExcludedByCustomIgnore(file, customIgnore)) skippedByName += 1;
502
+ else if (IMAGE_EXTENSIONS.has(extensionOf(file))) skippedBinary += 1;
337
503
  else includable.push(file);
338
504
  }
339
- const scanWindow = includable.slice(0, AUTO_READ_ALL_MAX_FILES * SCAN_LIMIT_MULTIPLIER);
505
+ const changed = source === "git" ? await listChangedFiles(cwd) : new Set<string>();
506
+ const sourceTopLevel = detectSourceTopLevel(includable);
507
+ const scanWindow = rankFiles(includable, changed, sourceTopLevel).slice(0, AUTO_READ_ALL_MAX_FILES * SCAN_LIMIT_MULTIPLIER);
340
508
  const sized: string[] = [];
341
- let skippedBinary = 0;
342
- let skippedLarge = 0;
343
- let skippedOther = 0;
344
509
 
345
510
  await forEachLimit(scanWindow, SCAN_CONCURRENCY, async (file) => {
346
511
  let stats;
@@ -358,10 +523,6 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
358
523
  skippedLarge += 1;
359
524
  return;
360
525
  }
361
- if (IMAGE_EXTENSIONS.has(extensionOf(file))) {
362
- skippedBinary += 1;
363
- return;
364
- }
365
526
  sized.push(file);
366
527
  });
367
528
 
@@ -372,8 +533,7 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
372
533
  else if (nul) skippedBinary += 1;
373
534
  else textual.push(file);
374
535
  });
375
- textual.sort();
376
- const files = textual.slice(0, AUTO_READ_ALL_MAX_FILES);
536
+ const files = rankFiles(textual, changed, sourceTopLevel).slice(0, AUTO_READ_ALL_MAX_FILES);
377
537
 
378
538
  return { files, source, discovered: unique.length, skippedBinary, skippedLarge, skippedOther, skippedByName };
379
539
  }
@@ -430,34 +590,52 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
430
590
  const outline = isOutlineAutoReadAll(mode);
431
591
  const sections: AutoReadAllSection[] = [];
432
592
  const omitted: string[] = [];
593
+ const backlog: string[] = [];
594
+ const usedByDir = new Map<string, number>();
595
+ const dirBudget = Math.max(1, Math.floor(budgetBytes * AUTO_READ_ALL_DIR_BUDGET_SHARE));
433
596
  let bytes = 0;
434
597
  let completeFiles = 0;
435
- for (const file of discovery.files) {
598
+
599
+ const take = async (file: string, respectDirBudget: boolean): Promise<void> => {
436
600
  if (sections.length > 0 && bytes >= budgetBytes) {
437
601
  omitted.push(file);
438
- continue;
602
+ return;
439
603
  }
440
- if (!outline && sections.length > 0) {
604
+ const group = topSegmentOf(file) ?? ".";
605
+ const used = usedByDir.get(group) ?? 0;
606
+ if (sections.length > 0) {
441
607
  const size = await candidateFileBytes(cwd, file);
442
608
  if (size !== undefined && bytes + size > budgetBytes) {
443
609
  omitted.push(file);
444
- continue;
610
+ return;
611
+ }
612
+ if (respectDirBudget && size !== undefined && used + size > dirBudget) {
613
+ backlog.push(file);
614
+ return;
445
615
  }
446
616
  }
447
617
  const section = await renderFile(file, cwd, outline);
448
618
  if (section === undefined) {
449
619
  omitted.push(file);
450
- continue;
620
+ return;
451
621
  }
452
622
  const sectionBytes = Buffer.byteLength(section.text, "utf-8") + 1;
453
623
  if (sections.length > 0 && bytes + sectionBytes > budgetBytes) {
454
624
  omitted.push(file);
455
- continue;
625
+ return;
626
+ }
627
+ if (sections.length > 0 && respectDirBudget && used + sectionBytes > dirBudget) {
628
+ backlog.push(file);
629
+ return;
456
630
  }
457
631
  sections.push(section);
458
632
  bytes += sectionBytes;
633
+ usedByDir.set(group, used + sectionBytes);
459
634
  if (section.complete) completeFiles += 1;
460
- }
635
+ };
636
+
637
+ for (const file of discovery.files) await take(file, true);
638
+ for (const file of backlog) await take(file, false);
461
639
  if (sections.length === 0) return undefined;
462
640
  const sectionTexts = sections.map((section) => section.text);
463
641
  const coverage = `[coverage: ${completeFiles} complete]`;
package/src/constants.ts CHANGED
@@ -36,3 +36,4 @@ export const AUTO_READ_ALL_MAX_FILES = 500;
36
36
  export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;
37
37
  export const AUTO_READ_ALL_MIN_BUDGET_BYTES = 200_000;
38
38
  export const AUTO_READ_ALL_MAX_BUDGET_BYTES = 2_000_000;
39
+ export const AUTO_READ_ALL_DIR_BUDGET_SHARE = 0.4;
@@ -166,7 +166,7 @@ export function planEdit(
166
166
  const fileHashes = precomputedHashes;
167
167
  const warnings: string[] = [];
168
168
 
169
- const rangeFixed = swapReversedRanges(edit, fileHashes);
169
+ const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
170
170
  const prefixFixed = stripDiffPrefixes(
171
171
  stripBarePrefixes(rangeFixed, warnings, options?.stripWarning),
172
172
  warnings,
@@ -265,6 +265,7 @@ export function stripDiffPrefixes(edit: HEdit, warnings: string[], location: Str
265
265
  export function swapReversedRanges(
266
266
  edit: HEdit,
267
267
  fileHashes: string[],
268
+ warnings?: string[],
268
269
  ): HEdit {
269
270
  const lineByHash = new Map<string, number>();
270
271
  for (let i = 0; i < fileHashes.length; i++) {
@@ -280,6 +281,7 @@ export function swapReversedRanges(
280
281
  ) {
281
282
  return edit;
282
283
  }
284
+ warnings?.push(`[H_RANGE_REVERSED] "remove_from" refers to line ${startLine} after "remove_to" (line ${endLine}); lines ${endLine}-${startLine} were replaced in document order.`);
283
285
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
284
286
  }
285
287
 
@@ -4,7 +4,7 @@ import * as Diff from "diff";
4
4
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
5
5
  import { commitEdit } from "./commit";
6
6
  import { readNormFile, type NormFile } from "./file-reader";
7
- import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
7
+ import { fmtRegion, MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
8
8
  import { servedForPath, withAnchorSession } from "./anchor-registry";
9
9
  import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
10
10
  import { loadP, loadGuidelines } from "./prompts";
@@ -16,9 +16,9 @@ import {
16
16
  type ReplaceMatchReq,
17
17
  } from "./payload-contract";
18
18
  import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
19
- import { toLF } from "./normalize";
19
+ import { toLF, type LineEnding } from "./normalize";
20
20
  import { isFidelitySensitiveChar } from "./edit-fidelity";
21
- import { countNewlines } from "./line-endings";
21
+ import { countNewlines, splitWithEndings } from "./line-endings";
22
22
  import { MAX_RANGE_STALE_LINES } from "./constants";
23
23
  import {
24
24
  assertBoundaryLinesServed,
@@ -44,6 +44,7 @@ interface MatchRefs {
44
44
  export interface MatchPlan {
45
45
  editParams: { remove_from: string; remove_to: string; text: string };
46
46
  warnings: string[];
47
+ contentSeparators: (LineEnding | undefined)[];
47
48
  servedOverride?: ReadonlyMap<string, string>;
48
49
  }
49
50
 
@@ -51,18 +52,46 @@ function formatMatchRange(start: number, end: number): string {
51
52
  return start === end ? `line ${start}` : `lines ${start}-${end}`;
52
53
  }
53
54
 
54
- function replaceAllOccurrences(text: string, oldText: string, newText: string): { text: string; count: number } {
55
- let out = "";
55
+ function replaceAllOccurrences(
56
+ text: string,
57
+ oldText: string,
58
+ newText: string,
59
+ ): { text: string; separators: (LineEnding | undefined)[]; count: number } {
60
+ const parts: string[] = [];
61
+ const inserted: Array<{ start: number; end: number }> = [];
62
+ let length = 0;
63
+ const append = (chunk: string, fromNewText: boolean): void => {
64
+ if (fromNewText) inserted.push({ start: length, end: length + chunk.length });
65
+ parts.push(chunk);
66
+ length += chunk.length;
67
+ };
56
68
  let cursor = 0;
57
69
  let count = 0;
58
70
  for (;;) {
59
71
  const found = text.indexOf(oldText, cursor);
60
72
  if (found < 0) break;
61
- out += text.slice(cursor, found) + newText;
73
+ append(text.slice(cursor, found), false);
74
+ append(newText, true);
62
75
  cursor = found + oldText.length;
63
76
  count += 1;
64
77
  }
65
- return count === 0 ? { text, count: 0 } : { text: out + text.slice(cursor), count };
78
+ if (count === 0) return { text, separators: [], count: 0 };
79
+ append(text.slice(cursor), false);
80
+ const replacement = parts.join("");
81
+ const parsed = splitWithEndings(replacement);
82
+ const separators: (LineEnding | undefined)[] = [];
83
+ let offset = 0;
84
+ let insertedIndex = 0;
85
+ for (let index = 0; index < parsed.lines.length; index++) {
86
+ offset += parsed.lines[index]!.length;
87
+ const ending = parsed.endings[index];
88
+ if (ending === undefined) continue;
89
+ while (insertedIndex < inserted.length && inserted[insertedIndex]!.end <= offset) insertedIndex += 1;
90
+ const fromNewText = insertedIndex < inserted.length && offset >= inserted[insertedIndex]!.start && offset < inserted[insertedIndex]!.end;
91
+ separators.push(fromNewText ? ending : undefined);
92
+ offset += ending.length;
93
+ }
94
+ return { text: replacement, separators, count };
66
95
  }
67
96
 
68
97
  const RETYPE_DIFF_MAX_CHARS = 20_000;
@@ -206,10 +235,11 @@ export function buildReplaceMatchEdit(
206
235
  const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
207
236
  const oldText = toLF(req.old_string);
208
237
  const newText = toLF(req.new_string);
209
- const { text: replacement, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
238
+ const { text: replacement, separators: replacementSeparators, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
210
239
  if (count === 0) {
211
240
  throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, rangeText, oldText));
212
241
  }
242
+ const contentSeparators = parsePayloadText(replacement).lines.map((_, index) => replacementSeparators[index]);
213
243
  const startRef = fromLine <= toLine ? refs.from : refs.to;
214
244
  const endRef = fromLine <= toLine ? refs.to : refs.from;
215
245
  const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
@@ -228,6 +258,7 @@ export function buildReplaceMatchEdit(
228
258
  text: replacement,
229
259
  },
230
260
  warnings,
261
+ contentSeparators,
231
262
  ...(servedOverride !== undefined ? { servedOverride } : {}),
232
263
  };
233
264
  }
@@ -251,6 +282,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
251
282
  return {
252
283
  hedit,
253
284
  warnings: [...warnings, ...hints, ...resWarnings, ...matchPlan.warnings],
285
+ contentSeparators: matchPlan.contentSeparators,
254
286
  planned,
255
287
  foldedLines: 0,
256
288
  ...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
@@ -286,6 +318,7 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
286
318
  noPersist: true,
287
319
  preloadedNorm: preload,
288
320
  served: plan.servedOverride,
321
+ endingOverrides: plan.contentSeparators,
289
322
  signal,
290
323
  });
291
324
  return previewFromPipe(pipe);
@@ -367,6 +400,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
367
400
  signal,
368
401
  preloadedNorm: preload,
369
402
  served: plan.servedOverride,
403
+ endingOverrides: plan.contentSeparators,
370
404
  });
371
405
  return commitEdit(pipe, {
372
406
  path: pipe.path,
package/src/replace.ts CHANGED
@@ -158,9 +158,11 @@ function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[]
158
158
  const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
159
159
  const separators: (LineEnding | undefined)[] = new Array(length);
160
160
  for (let index = 0; index < length; index++) {
161
- separators[index] = overrides[index] ?? edit.content_separators?.[index];
161
+ separators[index] = index < overrides.length ? overrides[index] : edit.content_separators?.[index];
162
+ }
163
+ if (separators.every((ending) => ending === undefined)) {
164
+ return edit.content_separators === undefined ? edit : { ...edit, content_separators: undefined };
162
165
  }
163
- if (separators.every((ending) => ending === undefined)) return edit;
164
166
  return { ...edit, content_separators: separators };
165
167
  }
166
168
 
@@ -1 +1 @@
1
- Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines after it — no line-number math. Page long files with `offset` and `limit`; when output says truncated, continue from the hinted `offset`. A stale anchor is refused with the current rows and fresh anchors. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
1
+ Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines from that anchor row onward — no line-number math. Page long files with `offset` and `limit`; when output says truncated, continue from the hinted `offset`. A stale anchor is refused with the current rows and fresh anchors. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.