pi-hashline-edit-pro 6.4.4 → 6.4.6
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 +3 -3
- package/index.ts +54 -12
- package/package.json +1 -1
- package/src/anchor-registry.ts +25 -3
- package/src/auto-read-all.ts +195 -17
- package/src/batch.ts +2 -2
- package/src/commit.ts +26 -14
- package/src/constants.ts +1 -0
- package/src/copy-move.ts +13 -2
- package/src/write-hook.ts +6 -2
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
|
|
|
@@ -482,12 +482,12 @@ Full reference:
|
|
|
482
482
|
| `[E_UNDO_NONE]` | `undo_last_change` found no recorded edit for the file. Nothing was changed; make an edit first. |
|
|
483
483
|
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
|
|
484
484
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
485
|
-
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran
|
|
485
|
+
| `[E_REGISTRY]` | The anchor registry was not initialized, or the session's registry was released because the session was replaced or reloaded; a serve or edit ran without a usable session. Retry the operation in the current session. |
|
|
486
486
|
| `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
|
|
487
487
|
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber:│ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
488
488
|
| `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
|
|
489
489
|
| `[E_BATCH_OVERLAP]` | Batched edit 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. |
|
|
490
|
-
| `[E_OP_ABORTED]` |
|
|
490
|
+
| `[E_OP_ABORTED]` | The edit was aborted before writing: the file changed or was deleted after it was read (a single edit or a cross-file move reports the file; a batch reports the batch and the failing call), the base read failed, a cross-file member could not be prepared, or the composed result exceeded a limit. Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The batch abort names the failing call and its error code when one is known. |
|
|
491
491
|
| `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences or look-around, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
|
|
492
492
|
| `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
|
|
493
493
|
|
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 { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
36
|
+
import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, sessionContextReadable, 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,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
|
|
80
|
+
const pendingRegistrySessions: PendingRegistrySession[] = [];
|
|
76
81
|
|
|
77
82
|
async function refreshEditTools(): Promise<void> {
|
|
78
83
|
try {
|
|
@@ -145,8 +150,26 @@ export default function (pi: ExtensionAPI): void {
|
|
|
145
150
|
pi.setActiveTools([...new Set([...base, ...enabled])]);
|
|
146
151
|
}
|
|
147
152
|
|
|
148
|
-
|
|
149
|
-
|
|
153
|
+
function captureRegistrySession(ctx: AnchorSessionContext | undefined): void {
|
|
154
|
+
const key = sessionKeyFor(ctx);
|
|
155
|
+
const entry: PendingRegistrySession = {
|
|
156
|
+
key,
|
|
157
|
+
alive: () => sessionContextReadable(ctx),
|
|
158
|
+
};
|
|
159
|
+
const index = key === undefined ? -1 : pendingRegistrySessions.findIndex((candidate) => candidate.key === key);
|
|
160
|
+
if (index >= 0) pendingRegistrySessions[index] = entry;
|
|
161
|
+
else pendingRegistrySessions.push(entry);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function releaseCapturedRegistrySession(key: string): void {
|
|
165
|
+
const index = pendingRegistrySessions.findIndex((entry) => entry.key === key);
|
|
166
|
+
if (index >= 0) pendingRegistrySessions.splice(index, 1);
|
|
167
|
+
releaseRegistrySession(key);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T | undefined> {
|
|
171
|
+
if (!sessionContextReadable(ctx)) return undefined;
|
|
172
|
+
captureRegistrySession(ctx);
|
|
150
173
|
return withAnchorSession(ctx, fn);
|
|
151
174
|
}
|
|
152
175
|
|
|
@@ -197,19 +220,38 @@ export default function (pi: ExtensionAPI): void {
|
|
|
197
220
|
}
|
|
198
221
|
}));
|
|
199
222
|
|
|
200
|
-
pi.on("session_shutdown", async () => {
|
|
223
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
201
224
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
225
|
+
let key: string | undefined;
|
|
226
|
+
let readable = true;
|
|
227
|
+
try {
|
|
228
|
+
key = sessionKeyFor(ctx);
|
|
229
|
+
} catch {
|
|
230
|
+
readable = false;
|
|
231
|
+
}
|
|
232
|
+
if (readable && key !== undefined) {
|
|
233
|
+
releaseCapturedRegistrySession(key);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (readable) {
|
|
237
|
+
const dead = pendingRegistrySessions.findIndex((entry) => entry.key === undefined && !entry.alive());
|
|
238
|
+
const fallback = pendingRegistrySessions.findIndex((entry) => entry.key === undefined);
|
|
239
|
+
const index = dead >= 0 ? dead : fallback;
|
|
240
|
+
if (index >= 0) pendingRegistrySessions.splice(index, 1);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const dead = pendingRegistrySessions.findIndex((entry) => !entry.alive());
|
|
244
|
+
const entry = dead >= 0 ? pendingRegistrySessions.splice(dead, 1)[0] : pendingRegistrySessions.shift();
|
|
245
|
+
if (entry?.key !== undefined) releaseRegistrySession(entry.key);
|
|
204
246
|
} catch (error) {
|
|
205
247
|
console.error("Failed to release anchor registry session:", error);
|
|
206
248
|
}
|
|
207
249
|
});
|
|
208
|
-
pi.on("model_select", async (event, ctx) =>
|
|
250
|
+
pi.on("model_select", async (event, ctx) => withRegistrySession(ctx, async () => {
|
|
209
251
|
await syncModelGate(event.model, ctx.cwd);
|
|
210
252
|
}));
|
|
211
253
|
|
|
212
|
-
pi.on("before_agent_start", async (_event, ctx) =>
|
|
254
|
+
pi.on("before_agent_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
|
|
213
255
|
await syncModelGate(ctx.model, ctx.cwd);
|
|
214
256
|
if (autoReadAll === "off" || autoReadAllInjected) return;
|
|
215
257
|
if (modelDisabled(ctx.model, disableOnModels)) return;
|
|
@@ -287,12 +329,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
287
329
|
|
|
288
330
|
pi.registerCommand("clear-anchors", {
|
|
289
331
|
description: "Clear the session's anchor claims (path-free resolution state); anchors are re-claimed on the next read",
|
|
290
|
-
handler: async (_args, ctx) =>
|
|
332
|
+
handler: async (_args, ctx) => withRegistrySession(ctx, async () => {
|
|
291
333
|
clearRegistry();
|
|
292
334
|
ctx.ui.notify(`Anchor claims cleared for this session`, "info");
|
|
293
335
|
}),
|
|
294
336
|
});
|
|
295
|
-
pi.on("message_end", async (event, ctx) =>
|
|
337
|
+
pi.on("message_end", async (event, ctx) => withRegistrySession(ctx, async () => {
|
|
296
338
|
try {
|
|
297
339
|
await planAssistantMessage(event.message, ctx.cwd);
|
|
298
340
|
} catch (error) {
|
|
@@ -307,7 +349,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
307
349
|
console.error("Failed to finalize edit batch:", error);
|
|
308
350
|
}
|
|
309
351
|
});
|
|
310
|
-
pi.on("tool_result", async (event, ctx) =>
|
|
352
|
+
pi.on("tool_result", async (event, ctx) => withRegistrySession(ctx, async () => {
|
|
311
353
|
if (event.isError) return;
|
|
312
354
|
const gated = modelDisabled(ctx.model, disableOnModels);
|
|
313
355
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.4.
|
|
3
|
+
"version": "6.4.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -406,7 +406,7 @@ async function ensureRegistryForKey(key: string, sessionFile: string | undefined
|
|
|
406
406
|
try {
|
|
407
407
|
await promise;
|
|
408
408
|
} finally {
|
|
409
|
-
pendingInits.delete(key);
|
|
409
|
+
if (pendingInits.get(key) === promise) pendingInits.delete(key);
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
|
|
@@ -435,6 +435,15 @@ function sessionFileFor(ctx: AnchorSessionContext | undefined): string | undefin
|
|
|
435
435
|
return typeof sessionFile === "string" && sessionFile.length > 0 ? sessionFile : undefined;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
export function sessionContextReadable(ctx: AnchorSessionContext | undefined): boolean {
|
|
439
|
+
try {
|
|
440
|
+
void ctx?.sessionManager;
|
|
441
|
+
return true;
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
438
447
|
export async function withAnchorSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
|
|
439
448
|
const key = sessionKeyFor(ctx);
|
|
440
449
|
if (key === undefined) return fn();
|
|
@@ -712,6 +721,9 @@ export function ownersForPath(path: string): Map<string, string> {
|
|
|
712
721
|
export function ensureRegistry(): void {
|
|
713
722
|
const key = activeKey();
|
|
714
723
|
if (key && registries.has(key)) return;
|
|
724
|
+
if (sessionScope.getStore() !== undefined) {
|
|
725
|
+
throw new Error("[E_REGISTRY] The anchor registry for this session is no longer available; the session was replaced or reloaded. Retry the operation in the new session.");
|
|
726
|
+
}
|
|
715
727
|
initRegistry(undefined).catch(() => undefined);
|
|
716
728
|
}
|
|
717
729
|
|
|
@@ -973,8 +985,18 @@ export async function allocateFileAnchors(
|
|
|
973
985
|
if (previousState && snapshotMatchesAllocation(registry, path, previousState.contentChecksum)) {
|
|
974
986
|
prevAnchors = previousState.anchors;
|
|
975
987
|
prevChecksums = previousState.checksums;
|
|
976
|
-
if (!prevChecksums
|
|
977
|
-
|
|
988
|
+
if (!prevChecksums) {
|
|
989
|
+
if (previousState.contentChecksum === contentChecksum(content)) {
|
|
990
|
+
prevChecksums = checksums;
|
|
991
|
+
} else if (!shadow) {
|
|
992
|
+
const state = current();
|
|
993
|
+
const stale = state && prevAnchors ? prevAnchors.filter((anchor) => state.owned.get(anchor)?.path === path) : [];
|
|
994
|
+
if (state && stale.length > 0) {
|
|
995
|
+
const usedAt = state.lastUsedAt.get(path);
|
|
996
|
+
freeAnchors(path, stale);
|
|
997
|
+
if (usedAt !== undefined) state.lastUsedAt.set(path, usedAt);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
978
1000
|
}
|
|
979
1001
|
}
|
|
980
1002
|
}
|
package/src/auto-read-all.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
602
|
+
return;
|
|
439
603
|
}
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/batch.ts
CHANGED
|
@@ -505,7 +505,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
505
505
|
edits_noop: piece.noop ? 1 : 0,
|
|
506
506
|
warnings: 0,
|
|
507
507
|
classification: piece.noop ? "noop" : "applied",
|
|
508
|
-
...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
|
|
508
|
+
...(piece.noop ? {} : { added_lines: added, removed_lines: Math.max(0, piece.end - piece.start + 1 - piece.foldedLines) }),
|
|
509
509
|
};
|
|
510
510
|
const structuredContent: EditStructured = {
|
|
511
511
|
ok: true,
|
|
@@ -1102,7 +1102,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
1102
1102
|
let added = 0;
|
|
1103
1103
|
let removed = 0;
|
|
1104
1104
|
for (const piece of appliedPieces) {
|
|
1105
|
-
removed += piece.end - piece.start + 1;
|
|
1105
|
+
removed += Math.max(0, piece.end - piece.start + 1 - piece.foldedLines);
|
|
1106
1106
|
added += Math.max(0, piece.newLines.length - piece.foldedLines);
|
|
1107
1107
|
}
|
|
1108
1108
|
const verb = batchVerb(runtime);
|
package/src/commit.ts
CHANGED
|
@@ -14,6 +14,28 @@ import { spanForEdit } from "./replace";
|
|
|
14
14
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
15
15
|
import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
16
16
|
import { toEditVerb } from "./structured";
|
|
17
|
+
export type FileStateSinceRead = "unchanged" | "deleted" | "changed";
|
|
18
|
+
|
|
19
|
+
export async function fileStateSinceRead(
|
|
20
|
+
mutationTargetPath: string,
|
|
21
|
+
displayPath: string,
|
|
22
|
+
expectedNormalized: string,
|
|
23
|
+
signal?: AbortSignal,
|
|
24
|
+
): Promise<FileStateSinceRead> {
|
|
25
|
+
abortIf(signal);
|
|
26
|
+
let raw: string | undefined;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(mutationTargetPath, "utf-8");
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const code = errCode(error);
|
|
31
|
+
if (code === "ENOENT") raw = undefined;
|
|
32
|
+
else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${displayPath}`);
|
|
33
|
+
else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${displayPath}`);
|
|
34
|
+
else throw mapIoError(error, "read", displayPath);
|
|
35
|
+
}
|
|
36
|
+
if (raw === undefined) return "deleted";
|
|
37
|
+
return toLF(stripBOM(raw).text) === expectedNormalized ? "unchanged" : "changed";
|
|
38
|
+
}
|
|
17
39
|
export interface CommitMeta {
|
|
18
40
|
editAnchors?: [string, string];
|
|
19
41
|
anchorCarry?: number;
|
|
@@ -91,21 +113,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
91
113
|
);
|
|
92
114
|
}
|
|
93
115
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
98
|
-
} catch (error) {
|
|
99
|
-
const code = errCode(error);
|
|
100
|
-
if (code === "ENOENT") currentRaw = undefined;
|
|
101
|
-
else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${path}`);
|
|
102
|
-
else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
|
|
103
|
-
else throw mapIoError(error, "read", path);
|
|
104
|
-
}
|
|
105
|
-
if (currentRaw === undefined) {
|
|
116
|
+
const state = await fileStateSinceRead(mutationTargetPath, path, pipe.originalNormalized, signal);
|
|
117
|
+
if (state === "deleted") {
|
|
106
118
|
throw new Error(`[E_OP_ABORTED] Edit aborted: the file was deleted after the edit started.`);
|
|
107
119
|
}
|
|
108
|
-
if (
|
|
120
|
+
if (state === "changed") {
|
|
109
121
|
throw new Error(`[E_OP_ABORTED] Edit aborted: the file changed after the edit started. Call read for fresh anchors and retry.`);
|
|
110
122
|
}
|
|
111
123
|
const undo = await saveUndo(mutationTargetPath, {
|
|
@@ -141,7 +153,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
141
153
|
firstChangedLine: pipe.firstChangedLine,
|
|
142
154
|
lastChangedLine: pipe.lastChangedLine,
|
|
143
155
|
addedLines: Math.max(0, pipe.totalAddedLines - (meta.foldedAnchorLines ?? 0)),
|
|
144
|
-
removedLines: pipe.totalRemovedLines,
|
|
156
|
+
removedLines: Math.max(0, pipe.totalRemovedLines - (meta.foldedAnchorLines ?? 0)),
|
|
145
157
|
};
|
|
146
158
|
|
|
147
159
|
let resultHashes: string[];
|
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;
|
package/src/copy-move.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, withFil
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { constants } from "node:fs";
|
|
5
5
|
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
|
|
6
|
-
import { commitEdit } from "./commit";
|
|
6
|
+
import { commitEdit, fileStateSinceRead } from "./commit";
|
|
7
7
|
import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
|
|
8
8
|
import { preEditSnapshotFor } from "./pre-edit-snapshot";
|
|
9
9
|
import {
|
|
@@ -377,6 +377,17 @@ async function commitMovePair(input: {
|
|
|
377
377
|
warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
const destinationState = await fileStateSinceRead(input.destination.mutationTargetPath, input.destination.displayPath, input.destination.pipe.originalNormalized, input.signal);
|
|
381
|
+
if (destinationState !== "unchanged") {
|
|
382
|
+
const verb = destinationState === "deleted" ? "was deleted" : "changed";
|
|
383
|
+
throw new Error(`[E_OP_ABORTED] Edit aborted: ${input.destination.displayPath} ${verb} after the move started. Call read for fresh anchors and retry.`);
|
|
384
|
+
}
|
|
385
|
+
const sourceState = await fileStateSinceRead(input.source.mutationTargetPath, input.source.displayPath, input.source.pipe.originalNormalized, input.signal);
|
|
386
|
+
if (sourceState !== "unchanged") {
|
|
387
|
+
const verb = sourceState === "deleted" ? "was deleted" : "changed";
|
|
388
|
+
throw new Error(`[E_OP_ABORTED] Edit aborted: ${input.source.displayPath} ${verb} after the move started. Call read for fresh anchors and retry.`);
|
|
389
|
+
}
|
|
390
|
+
|
|
380
391
|
const undoDestination = await saveUndo(input.destination.mutationTargetPath, pairEntry(input.destination.pipe, destinationSeparators));
|
|
381
392
|
if (!undoDestination.persisted) {
|
|
382
393
|
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${input.destination.displayPath}. Nothing was written.`);
|
|
@@ -451,7 +462,7 @@ async function commitMovePair(input: {
|
|
|
451
462
|
firstChangedLine: input.destination.pipe.firstChangedLine,
|
|
452
463
|
lastChangedLine: input.destination.pipe.lastChangedLine,
|
|
453
464
|
addedLines: Math.max(0, input.destination.pipe.totalAddedLines - input.destination.foldedAnchorLines),
|
|
454
|
-
removedLines: input.destination.pipe.totalRemovedLines,
|
|
465
|
+
removedLines: Math.max(0, input.destination.pipe.totalRemovedLines - input.destination.foldedAnchorLines),
|
|
455
466
|
},
|
|
456
467
|
...(destinationSpan !== undefined ? { spans: [destinationSpan] } : {}),
|
|
457
468
|
}, "moved", contextLines);
|
package/src/write-hook.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
3
|
import { HASH_SEP } from "./hashline/hash";
|
|
4
|
-
import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
4
|
+
import { sessionContextReadable, servedForPath, withAnchorSession, type AnchorSessionContext } from "./anchor-registry";
|
|
5
5
|
import { resolveInCwd } from "./fs-write";
|
|
6
6
|
import { abortIf, splitLines, isRec } from "./utils";
|
|
7
7
|
import type { ModelLike } from "./model-gate";
|
|
@@ -31,8 +31,12 @@ export async function servedHashEchoDenial(rawPath: string, content: string, cwd
|
|
|
31
31
|
return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line} contains the copied ${echo.hash}${HASH_SEP} anchor served for this file. Remove the copied anchors and retry.`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function withReadableSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T | undefined> {
|
|
35
|
+
return sessionContextReadable(ctx) ? withAnchorSession(ctx, fn) : Promise.resolve(undefined);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export function registerWriteHook(pi: ExtensionAPI, isModelDisabled?: (model: ModelLike | undefined) => boolean): void {
|
|
35
|
-
pi.on("tool_call", async (event, ctx) =>
|
|
39
|
+
pi.on("tool_call", async (event, ctx) => withReadableSession(ctx, async () => {
|
|
36
40
|
if (event.toolName !== "write") return;
|
|
37
41
|
if (isModelDisabled?.(ctx.model)) return;
|
|
38
42
|
const input = event.input as Record<string, unknown> | undefined;
|