pi-hashline-edit-pro 4.2.10 → 4.3.0
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 +26 -19
- package/index.ts +29 -10
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +2 -1
- package/prompts/replace.md +1 -1
- package/src/anchor-registry.ts +21 -31
- package/src/auto-read-all.ts +305 -0
- package/src/batch.ts +172 -84
- package/src/commit.ts +26 -26
- package/src/config-ui.ts +2 -1
- package/src/config.ts +52 -29
- package/src/constants.ts +6 -0
- package/src/fs-write.ts +1 -1
- package/src/grep.ts +12 -18
- package/src/hash-store/cache.ts +0 -18
- package/src/hash-store/retry.ts +0 -4
- package/src/hash-store/validation.ts +0 -31
- package/src/hash-store.ts +22 -32
- package/src/hashline/apply.ts +2 -2
- package/src/hashline/hash.ts +7 -8
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +3 -16
- package/src/hashline/resolve.ts +19 -39
- package/src/insert.ts +2 -4
- package/src/payload-contract.ts +3 -11
- package/src/read.ts +3 -3
- package/src/replace-diff.ts +131 -107
- package/src/replace-render.ts +37 -4
- package/src/replace-response.ts +42 -13
- package/src/replace-undo.ts +10 -10
- package/src/replace.ts +33 -54
- package/src/served.ts +7 -3
- package/src/utils.ts +9 -8
- package/src/write-hook.ts +0 -4
- package/src/boundary-bypass.ts +0 -51
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { lstat, open, readdir } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
AUTO_READ_ALL_MAX_BUDGET_BYTES,
|
|
7
|
+
AUTO_READ_ALL_MAX_FILE_BYTES,
|
|
8
|
+
AUTO_READ_ALL_MAX_FILES,
|
|
9
|
+
AUTO_READ_ALL_MIN_BUDGET_BYTES,
|
|
10
|
+
SNIFF_BYTES,
|
|
11
|
+
} from "./constants";
|
|
12
|
+
import { serveRows } from "./served";
|
|
13
|
+
import { readNormFile } from "./file-reader";
|
|
14
|
+
import { resolveRgPath } from "./grep";
|
|
15
|
+
import { MAX_HASH_LINES } from "./hashline";
|
|
16
|
+
import { fmtReadPreview } from "./read";
|
|
17
|
+
import { splitLines } from "./utils";
|
|
18
|
+
|
|
19
|
+
const EXEC_TIMEOUT_MS = 20_000;
|
|
20
|
+
const EXEC_MAX_BYTES = 64 * 1024 * 1024;
|
|
21
|
+
const SCAN_CONCURRENCY = 32;
|
|
22
|
+
const SCAN_LIMIT_MULTIPLIER = 4;
|
|
23
|
+
const MAX_REPORTED_OMISSIONS = 50;
|
|
24
|
+
|
|
25
|
+
const HEADER =
|
|
26
|
+
"[hashline auto-read-all] The content of every non-ignored project file is attached below with live hashline anchors. Each anchor│content row is already owned and served for this session, so replace and insert can target those anchors directly without calling read first. Rows with a truncation hint are only partially shown; call read with the hinted offset to see the rest.";
|
|
27
|
+
|
|
28
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
29
|
+
".avif",
|
|
30
|
+
".bmp",
|
|
31
|
+
".gif",
|
|
32
|
+
".heic",
|
|
33
|
+
".heif",
|
|
34
|
+
".ico",
|
|
35
|
+
".jpeg",
|
|
36
|
+
".jpg",
|
|
37
|
+
".jxl",
|
|
38
|
+
".png",
|
|
39
|
+
".psd",
|
|
40
|
+
".tif",
|
|
41
|
+
".tiff",
|
|
42
|
+
".webp",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export const AUTO_READ_ALL_EXCLUDED_NAMES = ["package-lock.json"];
|
|
46
|
+
|
|
47
|
+
const EXCLUDED_NAME_SET = new Set(AUTO_READ_ALL_EXCLUDED_NAMES);
|
|
48
|
+
|
|
49
|
+
const WALK_IGNORED_DIRS = new Set([
|
|
50
|
+
".git",
|
|
51
|
+
".hg",
|
|
52
|
+
".svn",
|
|
53
|
+
".tmp",
|
|
54
|
+
".cache",
|
|
55
|
+
".next",
|
|
56
|
+
".turbo",
|
|
57
|
+
".venv",
|
|
58
|
+
"venv",
|
|
59
|
+
"__pycache__",
|
|
60
|
+
"build",
|
|
61
|
+
"coverage",
|
|
62
|
+
"dist",
|
|
63
|
+
"node_modules",
|
|
64
|
+
"out",
|
|
65
|
+
"target",
|
|
66
|
+
"vendor",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export type AutoReadAllSource = "git" | "rg" | "walk";
|
|
70
|
+
|
|
71
|
+
export interface AutoReadAllDiscovery {
|
|
72
|
+
files: string[];
|
|
73
|
+
source: AutoReadAllSource;
|
|
74
|
+
discovered: number;
|
|
75
|
+
skippedBinary: number;
|
|
76
|
+
skippedLarge: number;
|
|
77
|
+
skippedOther: number;
|
|
78
|
+
skippedByName: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AutoReadAllInjection {
|
|
82
|
+
text: string;
|
|
83
|
+
files: number;
|
|
84
|
+
bytes: number;
|
|
85
|
+
omitted: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runCommand(command: string, args: string[], cwd: string): Promise<{ stdout: string; code: number }> {
|
|
89
|
+
return new Promise((resolveResult) => {
|
|
90
|
+
execFile(command, args, { cwd, timeout: EXEC_TIMEOUT_MS, maxBuffer: EXEC_MAX_BYTES }, (error, stdout) => {
|
|
91
|
+
if (error === null) {
|
|
92
|
+
resolveResult({ stdout: stdout ?? "", code: 0 });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : 1;
|
|
96
|
+
resolveResult({ stdout: stdout ?? "", code });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function listFromGit(cwd: string): Promise<string[] | undefined> {
|
|
102
|
+
const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd);
|
|
103
|
+
if (result.code !== 0) return undefined;
|
|
104
|
+
return result.stdout.split("\0").filter((entry) => entry.length > 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listFromRg(cwd: string): Promise<string[] | undefined> {
|
|
108
|
+
let rgPath: string;
|
|
109
|
+
try {
|
|
110
|
+
rgPath = await resolveRgPath();
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const result = await runCommand(rgPath, ["--files", "--hidden", "--no-require-git", "--glob", "!.git", "--null"], cwd);
|
|
115
|
+
if (result.code === 1) return [];
|
|
116
|
+
if (result.code !== 0) return undefined;
|
|
117
|
+
return result.stdout.split("\0").filter((entry) => entry.length > 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function walkDir(dir: string, base: string, out: string[]): Promise<void> {
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (entry.isSymbolicLink()) continue;
|
|
129
|
+
const full = join(dir, entry.name);
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
if (WALK_IGNORED_DIRS.has(entry.name)) continue;
|
|
132
|
+
await walkDir(full, base, out);
|
|
133
|
+
} else if (entry.isFile()) {
|
|
134
|
+
out.push(toPosix(relative(base, full)));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toPosix(path: string): string {
|
|
140
|
+
return sep === "/" ? path : path.split(sep).join("/");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function baseNameOf(path: string): string {
|
|
144
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extensionOf(path: string): string {
|
|
148
|
+
const base = baseNameOf(path);
|
|
149
|
+
const dot = base.lastIndexOf(".");
|
|
150
|
+
return dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function hasNulByte(path: string): Promise<boolean | undefined> {
|
|
154
|
+
let handle;
|
|
155
|
+
try {
|
|
156
|
+
handle = await open(path, "r");
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const buffer = Buffer.alloc(SNIFF_BYTES);
|
|
162
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
163
|
+
return buffer.subarray(0, bytesRead).includes(0);
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
} finally {
|
|
167
|
+
await handle.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function forEachLimit<T>(items: T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {
|
|
172
|
+
let index = 0;
|
|
173
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
174
|
+
while (index < items.length) {
|
|
175
|
+
const item = items[index];
|
|
176
|
+
index += 1;
|
|
177
|
+
if (item !== undefined) await work(item);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
await Promise.all(workers);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function discoverAutoReadAllFiles(cwd: string): Promise<AutoReadAllDiscovery> {
|
|
184
|
+
let source: AutoReadAllSource = "git";
|
|
185
|
+
let candidates = await listFromGit(cwd);
|
|
186
|
+
if (candidates === undefined) {
|
|
187
|
+
candidates = await listFromRg(cwd);
|
|
188
|
+
source = "rg";
|
|
189
|
+
}
|
|
190
|
+
if (candidates === undefined) {
|
|
191
|
+
source = "walk";
|
|
192
|
+
const walked: string[] = [];
|
|
193
|
+
await walkDir(cwd, cwd, walked);
|
|
194
|
+
candidates = walked;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const unique = [...new Set(candidates.map(toPosix))].sort();
|
|
198
|
+
const includable: string[] = [];
|
|
199
|
+
let skippedByName = 0;
|
|
200
|
+
for (const file of unique) {
|
|
201
|
+
if (EXCLUDED_NAME_SET.has(baseNameOf(file).toLowerCase())) skippedByName += 1;
|
|
202
|
+
else includable.push(file);
|
|
203
|
+
}
|
|
204
|
+
const scanWindow = includable.slice(0, AUTO_READ_ALL_MAX_FILES * SCAN_LIMIT_MULTIPLIER);
|
|
205
|
+
const sized: string[] = [];
|
|
206
|
+
let skippedBinary = 0;
|
|
207
|
+
let skippedLarge = 0;
|
|
208
|
+
let skippedOther = 0;
|
|
209
|
+
|
|
210
|
+
await forEachLimit(scanWindow, SCAN_CONCURRENCY, async (file) => {
|
|
211
|
+
let stats;
|
|
212
|
+
try {
|
|
213
|
+
stats = await lstat(resolve(cwd, file));
|
|
214
|
+
} catch {
|
|
215
|
+
skippedOther += 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!stats.isFile()) {
|
|
219
|
+
skippedOther += 1;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (stats.size > AUTO_READ_ALL_MAX_FILE_BYTES) {
|
|
223
|
+
skippedLarge += 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (IMAGE_EXTENSIONS.has(extensionOf(file))) {
|
|
227
|
+
skippedBinary += 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
sized.push(file);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const textual: string[] = [];
|
|
234
|
+
await forEachLimit(sized, SCAN_CONCURRENCY, async (file) => {
|
|
235
|
+
const nul = await hasNulByte(resolve(cwd, file));
|
|
236
|
+
if (nul === undefined) skippedOther += 1;
|
|
237
|
+
else if (nul) skippedBinary += 1;
|
|
238
|
+
else textual.push(file);
|
|
239
|
+
});
|
|
240
|
+
textual.sort();
|
|
241
|
+
const files = textual.slice(0, AUTO_READ_ALL_MAX_FILES);
|
|
242
|
+
|
|
243
|
+
return { files, source, discovered: unique.length, skippedBinary, skippedLarge, skippedOther, skippedByName };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function renderFile(file: string, cwd: string): Promise<string | undefined> {
|
|
247
|
+
try {
|
|
248
|
+
const { normalized, fileHashes, absolutePath } = await readNormFile(file, cwd, { maxLines: MAX_HASH_LINES });
|
|
249
|
+
const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES);
|
|
250
|
+
serveRows(absolutePath, fileHashes, splitLines(normalized), preview.servedHashes);
|
|
251
|
+
return `=== ${file} ===\n${preview.text}`;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.error(`Auto-read all: skipped ${file}:`, error);
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted: string[]): string {
|
|
259
|
+
const notes: string[] = [];
|
|
260
|
+
const beyondCap = Math.max(
|
|
261
|
+
0,
|
|
262
|
+
discovery.discovered - discovery.files.length - discovery.skippedBinary - discovery.skippedLarge - discovery.skippedOther - discovery.skippedByName,
|
|
263
|
+
);
|
|
264
|
+
if (beyondCap > 0) notes.push(`${beyondCap} file(s) beyond the ${AUTO_READ_ALL_MAX_FILES}-file cap skipped`);
|
|
265
|
+
if (discovery.skippedBinary > 0) notes.push(`${discovery.skippedBinary} binary or image file(s) skipped`);
|
|
266
|
+
if (discovery.skippedLarge > 0) notes.push(`${discovery.skippedLarge} file(s) over ${formatSize(AUTO_READ_ALL_MAX_FILE_BYTES)} skipped`);
|
|
267
|
+
if (discovery.skippedOther > 0) notes.push(`${discovery.skippedOther} unreadable path(s) skipped`);
|
|
268
|
+
if (discovery.skippedByName > 0) notes.push(`${discovery.skippedByName} file(s) skipped by name (${AUTO_READ_ALL_EXCLUDED_NAMES.join(", ")})`);
|
|
269
|
+
const listed = omitted.slice(0, MAX_REPORTED_OMISSIONS).join(", ");
|
|
270
|
+
const more = omitted.length > MAX_REPORTED_OMISSIONS ? `, ... (+${omitted.length - MAX_REPORTED_OMISSIONS} more)` : "";
|
|
271
|
+
const omissionNote = omitted.length > 0 ? ` Not attached: ${listed}${more}. Use read for those.` : "";
|
|
272
|
+
const summary = notes.length > 0 ? notes.join("; ") + "." : "all discovered files attached.";
|
|
273
|
+
return `[hashline auto-read-all: ${attached} file(s) attached from ${discovery.source}; ${summary}${omissionNote}]`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number): Promise<AutoReadAllInjection | undefined> {
|
|
277
|
+
const discovery = await discoverAutoReadAllFiles(cwd);
|
|
278
|
+
if (discovery.files.length === 0) return undefined;
|
|
279
|
+
const sections: string[] = [];
|
|
280
|
+
const omitted: string[] = [];
|
|
281
|
+
let bytes = 0;
|
|
282
|
+
for (const file of discovery.files) {
|
|
283
|
+
const section = await renderFile(file, cwd);
|
|
284
|
+
if (section === undefined) {
|
|
285
|
+
omitted.push(file);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const sectionBytes = Buffer.byteLength(section, "utf-8") + 1;
|
|
289
|
+
if (sections.length > 0 && bytes + sectionBytes > budgetBytes) {
|
|
290
|
+
omitted.push(file);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
sections.push(section);
|
|
294
|
+
bytes += sectionBytes;
|
|
295
|
+
}
|
|
296
|
+
if (sections.length === 0) return undefined;
|
|
297
|
+
const text = `${HEADER}\n\n${sections.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
|
|
298
|
+
return { text, files: sections.length, bytes, omitted };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function autoReadAllBudget(model: { contextWindow?: number } | undefined): number {
|
|
302
|
+
const contextWindow = typeof model?.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow : 0;
|
|
303
|
+
const fromContext = Math.floor(contextWindow * 1.5);
|
|
304
|
+
return Math.min(AUTO_READ_ALL_MAX_BUDGET_BYTES, Math.max(AUTO_READ_ALL_MIN_BUDGET_BYTES, fromContext));
|
|
305
|
+
}
|