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 +4 -3
- package/index.ts +63 -9
- package/package.json +1 -1
- package/src/auto-read-all.ts +195 -17
- package/src/constants.ts +1 -0
- package/src/hashline/apply.ts +1 -1
- package/src/hashline/resolve.ts +2 -0
- package/src/replace-match.ts +42 -8
- package/src/replace.ts +4 -2
- package/tool-prompts/read.md +1 -1
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
+
"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",
|
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/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/hashline/apply.ts
CHANGED
|
@@ -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,
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -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
|
|
package/src/replace-match.ts
CHANGED
|
@@ -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(
|
|
55
|
-
|
|
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
|
-
|
|
73
|
+
append(text.slice(cursor, found), false);
|
|
74
|
+
append(newText, true);
|
|
62
75
|
cursor = found + oldText.length;
|
|
63
76
|
count += 1;
|
|
64
77
|
}
|
|
65
|
-
|
|
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]
|
|
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
|
|
package/tool-prompts/read.md
CHANGED
|
@@ -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
|
|
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.
|