pi-hashline-edit-pro 6.4.4 → 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
@@ -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
 
package/index.ts CHANGED
@@ -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,7 +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;
75
- const pendingRegistrySessionKeys: Array<string | undefined> = [];
80
+ const pendingRegistrySessions: PendingRegistrySession[] = [];
76
81
 
77
82
  async function refreshEditTools(): Promise<void> {
78
83
  try {
@@ -145,8 +150,32 @@ export default function (pi: ExtensionAPI): void {
145
150
  pi.setActiveTools([...new Set([...base, ...enabled])]);
146
151
  }
147
152
 
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
+
148
177
  async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
149
- pendingRegistrySessionKeys.push(sessionKeyFor(ctx));
178
+ captureRegistrySession(ctx);
150
179
  return withAnchorSession(ctx, fn);
151
180
  }
152
181
 
@@ -197,19 +226,38 @@ export default function (pi: ExtensionAPI): void {
197
226
  }
198
227
  }));
199
228
 
200
- pi.on("session_shutdown", async () => {
229
+ pi.on("session_shutdown", async (_event, ctx) => {
201
230
  try {
202
- const key = pendingRegistrySessionKeys.shift();
203
- 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);
204
252
  } catch (error) {
205
253
  console.error("Failed to release anchor registry session:", error);
206
254
  }
207
255
  });
208
- pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
256
+ pi.on("model_select", async (event, ctx) => withRegistrySession(ctx, async () => {
209
257
  await syncModelGate(event.model, ctx.cwd);
210
258
  }));
211
259
 
212
- pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
260
+ pi.on("before_agent_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
213
261
  await syncModelGate(ctx.model, ctx.cwd);
214
262
  if (autoReadAll === "off" || autoReadAllInjected) return;
215
263
  if (modelDisabled(ctx.model, disableOnModels)) return;
@@ -287,12 +335,12 @@ export default function (pi: ExtensionAPI): void {
287
335
 
288
336
  pi.registerCommand("clear-anchors", {
289
337
  description: "Clear the session's anchor claims (path-free resolution state); anchors are re-claimed on the next read",
290
- handler: async (_args, ctx) => withAnchorSession(ctx, async () => {
338
+ handler: async (_args, ctx) => withRegistrySession(ctx, async () => {
291
339
  clearRegistry();
292
340
  ctx.ui.notify(`Anchor claims cleared for this session`, "info");
293
341
  }),
294
342
  });
295
- pi.on("message_end", async (event, ctx) => withAnchorSession(ctx, async () => {
343
+ pi.on("message_end", async (event, ctx) => withRegistrySession(ctx, async () => {
296
344
  try {
297
345
  await planAssistantMessage(event.message, ctx.cwd);
298
346
  } catch (error) {
@@ -307,7 +355,7 @@ export default function (pi: ExtensionAPI): void {
307
355
  console.error("Failed to finalize edit batch:", error);
308
356
  }
309
357
  });
310
- pi.on("tool_result", async (event, ctx) => withAnchorSession(ctx, async () => {
358
+ pi.on("tool_result", async (event, ctx) => withRegistrySession(ctx, async () => {
311
359
  if (event.isError) return;
312
360
  const gated = modelDisabled(ctx.model, disableOnModels);
313
361
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.4",
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;