pi-hashline-edit-pro 4.2.11 → 4.3.1
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 +19 -11
- package/index.ts +30 -8
- package/package.json +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +0 -1
- package/prompts/insert.md +1 -1
- package/prompts/replace-guidelines.md +1 -1
- package/prompts/undo-last-change-guidelines.md +1 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/src/anchor-registry.ts +21 -31
- package/src/auto-read-all.ts +309 -0
- package/src/batch.ts +3 -3
- package/src/commit.ts +10 -19
- package/src/config-ui.ts +2 -1
- package/src/config.ts +68 -28
- package/src/constants.ts +6 -0
- package/src/edit-common.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/payload-contract.ts +4 -12
- package/src/read.ts +3 -3
- package/src/replace-diff.ts +107 -108
- package/src/replace-response.ts +37 -3
- package/src/replace-undo.ts +9 -9
- package/src/replace.ts +11 -5
- package/src/served.ts +7 -3
- package/src/utils.ts +9 -8
- package/src/write-hook.ts +0 -4
|
@@ -0,0 +1,309 @@
|
|
|
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 type { AutoReadAllMode } from "./config";
|
|
13
|
+
import { serveRows } from "./served";
|
|
14
|
+
import { readNormFile } from "./file-reader";
|
|
15
|
+
import { resolveRgPath } from "./grep";
|
|
16
|
+
import { MAX_HASH_LINES } from "./hashline";
|
|
17
|
+
import { fmtReadPreview } from "./read";
|
|
18
|
+
import { splitLines } from "./utils";
|
|
19
|
+
|
|
20
|
+
const EXEC_TIMEOUT_MS = 20_000;
|
|
21
|
+
const EXEC_MAX_BYTES = 64 * 1024 * 1024;
|
|
22
|
+
const SCAN_CONCURRENCY = 32;
|
|
23
|
+
const SCAN_LIMIT_MULTIPLIER = 4;
|
|
24
|
+
const MAX_REPORTED_OMISSIONS = 50;
|
|
25
|
+
|
|
26
|
+
const HEADER =
|
|
27
|
+
"[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.";
|
|
28
|
+
|
|
29
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
30
|
+
".avif",
|
|
31
|
+
".bmp",
|
|
32
|
+
".gif",
|
|
33
|
+
".heic",
|
|
34
|
+
".heif",
|
|
35
|
+
".ico",
|
|
36
|
+
".jpeg",
|
|
37
|
+
".jpg",
|
|
38
|
+
".jxl",
|
|
39
|
+
".png",
|
|
40
|
+
".psd",
|
|
41
|
+
".tif",
|
|
42
|
+
".tiff",
|
|
43
|
+
".webp",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export const AUTO_READ_ALL_EXCLUDED_NAMES = ["package-lock.json"];
|
|
47
|
+
|
|
48
|
+
const EXCLUDED_NAME_SET = new Set(AUTO_READ_ALL_EXCLUDED_NAMES);
|
|
49
|
+
|
|
50
|
+
const WALK_IGNORED_DIRS = new Set([
|
|
51
|
+
".git",
|
|
52
|
+
".hg",
|
|
53
|
+
".svn",
|
|
54
|
+
".tmp",
|
|
55
|
+
".cache",
|
|
56
|
+
".next",
|
|
57
|
+
".turbo",
|
|
58
|
+
".venv",
|
|
59
|
+
"venv",
|
|
60
|
+
"__pycache__",
|
|
61
|
+
"build",
|
|
62
|
+
"coverage",
|
|
63
|
+
"dist",
|
|
64
|
+
"node_modules",
|
|
65
|
+
"out",
|
|
66
|
+
"target",
|
|
67
|
+
"vendor",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
export type AutoReadAllSource = "git" | "rg" | "walk";
|
|
71
|
+
|
|
72
|
+
export interface AutoReadAllDiscovery {
|
|
73
|
+
files: string[];
|
|
74
|
+
source: AutoReadAllSource;
|
|
75
|
+
discovered: number;
|
|
76
|
+
skippedBinary: number;
|
|
77
|
+
skippedLarge: number;
|
|
78
|
+
skippedOther: number;
|
|
79
|
+
skippedByName: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AutoReadAllInjection {
|
|
83
|
+
text: string;
|
|
84
|
+
files: number;
|
|
85
|
+
bytes: number;
|
|
86
|
+
omitted: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runCommand(command: string, args: string[], cwd: string): Promise<{ stdout: string; code: number }> {
|
|
90
|
+
return new Promise((resolveResult) => {
|
|
91
|
+
execFile(command, args, { cwd, timeout: EXEC_TIMEOUT_MS, maxBuffer: EXEC_MAX_BYTES }, (error, stdout) => {
|
|
92
|
+
if (error === null) {
|
|
93
|
+
resolveResult({ stdout: stdout ?? "", code: 0 });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : 1;
|
|
97
|
+
resolveResult({ stdout: stdout ?? "", code });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function listFromGit(cwd: string): Promise<string[] | undefined> {
|
|
103
|
+
const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd);
|
|
104
|
+
if (result.code !== 0) return undefined;
|
|
105
|
+
return result.stdout.split("\0").filter((entry) => entry.length > 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function listFromRg(cwd: string): Promise<string[] | undefined> {
|
|
109
|
+
let rgPath: string;
|
|
110
|
+
try {
|
|
111
|
+
rgPath = await resolveRgPath();
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const result = await runCommand(rgPath, ["--files", "--hidden", "--no-require-git", "--glob", "!.git", "--null"], cwd);
|
|
116
|
+
if (result.code === 1) return [];
|
|
117
|
+
if (result.code !== 0) return undefined;
|
|
118
|
+
return result.stdout.split("\0").filter((entry) => entry.length > 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function walkDir(dir: string, base: string, out: string[]): Promise<void> {
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (entry.isSymbolicLink()) continue;
|
|
130
|
+
const full = join(dir, entry.name);
|
|
131
|
+
if (entry.isDirectory()) {
|
|
132
|
+
if (WALK_IGNORED_DIRS.has(entry.name)) continue;
|
|
133
|
+
await walkDir(full, base, out);
|
|
134
|
+
} else if (entry.isFile()) {
|
|
135
|
+
out.push(toPosix(relative(base, full)));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function toPosix(path: string): string {
|
|
141
|
+
return sep === "/" ? path : path.split(sep).join("/");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function baseNameOf(path: string): string {
|
|
145
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function extensionOf(path: string): string {
|
|
149
|
+
const base = baseNameOf(path);
|
|
150
|
+
const dot = base.lastIndexOf(".");
|
|
151
|
+
return dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function hasNulByte(path: string): Promise<boolean | undefined> {
|
|
155
|
+
let handle;
|
|
156
|
+
try {
|
|
157
|
+
handle = await open(path, "r");
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const buffer = Buffer.alloc(SNIFF_BYTES);
|
|
163
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
164
|
+
return buffer.subarray(0, bytesRead).includes(0);
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
} finally {
|
|
168
|
+
await handle.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function forEachLimit<T>(items: T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {
|
|
173
|
+
let index = 0;
|
|
174
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
175
|
+
while (index < items.length) {
|
|
176
|
+
const item = items[index];
|
|
177
|
+
index += 1;
|
|
178
|
+
if (item !== undefined) await work(item);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
await Promise.all(workers);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMode = "on"): Promise<AutoReadAllDiscovery> {
|
|
185
|
+
let source: AutoReadAllSource = "git";
|
|
186
|
+
let candidates = await listFromGit(cwd);
|
|
187
|
+
if (candidates === undefined) {
|
|
188
|
+
if (mode === "git") {
|
|
189
|
+
return { files: [], source: "git", discovered: 0, skippedBinary: 0, skippedLarge: 0, skippedOther: 0, skippedByName: 0 };
|
|
190
|
+
}
|
|
191
|
+
candidates = await listFromRg(cwd);
|
|
192
|
+
source = "rg";
|
|
193
|
+
}
|
|
194
|
+
if (candidates === undefined) {
|
|
195
|
+
source = "walk";
|
|
196
|
+
const walked: string[] = [];
|
|
197
|
+
await walkDir(cwd, cwd, walked);
|
|
198
|
+
candidates = walked;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const unique = [...new Set(candidates.map(toPosix))].sort();
|
|
202
|
+
const includable: string[] = [];
|
|
203
|
+
let skippedByName = 0;
|
|
204
|
+
for (const file of unique) {
|
|
205
|
+
if (EXCLUDED_NAME_SET.has(baseNameOf(file).toLowerCase())) skippedByName += 1;
|
|
206
|
+
else includable.push(file);
|
|
207
|
+
}
|
|
208
|
+
const scanWindow = includable.slice(0, AUTO_READ_ALL_MAX_FILES * SCAN_LIMIT_MULTIPLIER);
|
|
209
|
+
const sized: string[] = [];
|
|
210
|
+
let skippedBinary = 0;
|
|
211
|
+
let skippedLarge = 0;
|
|
212
|
+
let skippedOther = 0;
|
|
213
|
+
|
|
214
|
+
await forEachLimit(scanWindow, SCAN_CONCURRENCY, async (file) => {
|
|
215
|
+
let stats;
|
|
216
|
+
try {
|
|
217
|
+
stats = await lstat(resolve(cwd, file));
|
|
218
|
+
} catch {
|
|
219
|
+
skippedOther += 1;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!stats.isFile()) {
|
|
223
|
+
skippedOther += 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (stats.size > AUTO_READ_ALL_MAX_FILE_BYTES) {
|
|
227
|
+
skippedLarge += 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (IMAGE_EXTENSIONS.has(extensionOf(file))) {
|
|
231
|
+
skippedBinary += 1;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
sized.push(file);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const textual: string[] = [];
|
|
238
|
+
await forEachLimit(sized, SCAN_CONCURRENCY, async (file) => {
|
|
239
|
+
const nul = await hasNulByte(resolve(cwd, file));
|
|
240
|
+
if (nul === undefined) skippedOther += 1;
|
|
241
|
+
else if (nul) skippedBinary += 1;
|
|
242
|
+
else textual.push(file);
|
|
243
|
+
});
|
|
244
|
+
textual.sort();
|
|
245
|
+
const files = textual.slice(0, AUTO_READ_ALL_MAX_FILES);
|
|
246
|
+
|
|
247
|
+
return { files, source, discovered: unique.length, skippedBinary, skippedLarge, skippedOther, skippedByName };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function renderFile(file: string, cwd: string): Promise<string | undefined> {
|
|
251
|
+
try {
|
|
252
|
+
const { normalized, fileHashes, absolutePath } = await readNormFile(file, cwd, { maxLines: MAX_HASH_LINES });
|
|
253
|
+
const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES);
|
|
254
|
+
serveRows(absolutePath, fileHashes, splitLines(normalized), preview.servedHashes);
|
|
255
|
+
return `=== ${file} ===\n${preview.text}`;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.error(`Auto-read all: skipped ${file}:`, error);
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted: string[]): string {
|
|
263
|
+
const notes: string[] = [];
|
|
264
|
+
const beyondCap = Math.max(
|
|
265
|
+
0,
|
|
266
|
+
discovery.discovered - discovery.files.length - discovery.skippedBinary - discovery.skippedLarge - discovery.skippedOther - discovery.skippedByName,
|
|
267
|
+
);
|
|
268
|
+
if (beyondCap > 0) notes.push(`${beyondCap} file(s) beyond the ${AUTO_READ_ALL_MAX_FILES}-file cap skipped`);
|
|
269
|
+
if (discovery.skippedBinary > 0) notes.push(`${discovery.skippedBinary} binary or image file(s) skipped`);
|
|
270
|
+
if (discovery.skippedLarge > 0) notes.push(`${discovery.skippedLarge} file(s) over ${formatSize(AUTO_READ_ALL_MAX_FILE_BYTES)} skipped`);
|
|
271
|
+
if (discovery.skippedOther > 0) notes.push(`${discovery.skippedOther} unreadable path(s) skipped`);
|
|
272
|
+
if (discovery.skippedByName > 0) notes.push(`${discovery.skippedByName} file(s) skipped by name (${AUTO_READ_ALL_EXCLUDED_NAMES.join(", ")})`);
|
|
273
|
+
const listed = omitted.slice(0, MAX_REPORTED_OMISSIONS).join(", ");
|
|
274
|
+
const more = omitted.length > MAX_REPORTED_OMISSIONS ? `, ... (+${omitted.length - MAX_REPORTED_OMISSIONS} more)` : "";
|
|
275
|
+
const omissionNote = omitted.length > 0 ? ` Not attached: ${listed}${more}. Use read for those.` : "";
|
|
276
|
+
const summary = notes.length > 0 ? notes.join("; ") + "." : "all discovered files attached.";
|
|
277
|
+
return `[hashline auto-read-all: ${attached} file(s) attached from ${discovery.source}; ${summary}${omissionNote}]`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on"): Promise<AutoReadAllInjection | undefined> {
|
|
281
|
+
const discovery = await discoverAutoReadAllFiles(cwd, mode);
|
|
282
|
+
if (discovery.files.length === 0) return undefined;
|
|
283
|
+
const sections: string[] = [];
|
|
284
|
+
const omitted: string[] = [];
|
|
285
|
+
let bytes = 0;
|
|
286
|
+
for (const file of discovery.files) {
|
|
287
|
+
const section = await renderFile(file, cwd);
|
|
288
|
+
if (section === undefined) {
|
|
289
|
+
omitted.push(file);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const sectionBytes = Buffer.byteLength(section, "utf-8") + 1;
|
|
293
|
+
if (sections.length > 0 && bytes + sectionBytes > budgetBytes) {
|
|
294
|
+
omitted.push(file);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
sections.push(section);
|
|
298
|
+
bytes += sectionBytes;
|
|
299
|
+
}
|
|
300
|
+
if (sections.length === 0) return undefined;
|
|
301
|
+
const text = `${HEADER}\n\n${sections.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
|
|
302
|
+
return { text, files: sections.length, bytes, omitted };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function autoReadAllBudget(model: { contextWindow?: number } | undefined): number {
|
|
306
|
+
const contextWindow = typeof model?.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow : 0;
|
|
307
|
+
const fromContext = Math.floor(contextWindow * 1.5);
|
|
308
|
+
return Math.min(AUTO_READ_ALL_MAX_BUDGET_BYTES, Math.max(AUTO_READ_ALL_MIN_BUDGET_BYTES, fromContext));
|
|
309
|
+
}
|
package/src/batch.ts
CHANGED
|
@@ -17,12 +17,12 @@ import {
|
|
|
17
17
|
type PlannedEdit,
|
|
18
18
|
} from "./hashline";
|
|
19
19
|
import { boundaryDedupNoopWarning, boundaryDedupWarning, dedupRowsFromFixes } from "./commit";
|
|
20
|
-
import { adoptAnchors,
|
|
20
|
+
import { adoptAnchors, servedForPath } from "./anchor-registry";
|
|
21
21
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
22
22
|
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
23
23
|
import { saveUndo } from "./replace-undo";
|
|
24
24
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
25
|
-
import {
|
|
25
|
+
import { serveRows, servedHashesFromDiff } from "./served";
|
|
26
26
|
import { abortIf, assertLineLimit, errCode, isRec, splitLines } from "./utils";
|
|
27
27
|
import { MAX_BYTES } from "./constants";
|
|
28
28
|
|
|
@@ -750,7 +750,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
750
750
|
changed.details.diff = `${header}\n${changed.details.diff}`;
|
|
751
751
|
changed.details.diffLineNumbers?.unshift(undefined);
|
|
752
752
|
try {
|
|
753
|
-
|
|
753
|
+
serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
|
|
754
754
|
} catch (error) {
|
|
755
755
|
console.error("Failed to mark batch diff served:", error);
|
|
756
756
|
}
|
package/src/commit.ts
CHANGED
|
@@ -8,11 +8,10 @@ import { saveUndo } from "./replace-undo";
|
|
|
8
8
|
import { getDiffContextLines } from "./config";
|
|
9
9
|
import { safeSnapId } from "./file-reader";
|
|
10
10
|
import { writeAtomic } from "./fs-write";
|
|
11
|
-
import { servedHashesFromDiff,
|
|
11
|
+
import { servedHashesFromDiff, serveRows } from "./served";
|
|
12
12
|
import { lineHashes, type AutoFix } from "./hashline";
|
|
13
|
-
import {
|
|
13
|
+
import { spanForEdit } from "./replace";
|
|
14
14
|
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
15
|
-
import { markServed as markServedScoped } from "./anchor-registry";
|
|
16
15
|
export interface CommitMeta {
|
|
17
16
|
editAnchors?: [string, string];
|
|
18
17
|
path: string;
|
|
@@ -131,18 +130,14 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
131
130
|
removedLines: pipe.totalRemovedLines,
|
|
132
131
|
};
|
|
133
132
|
|
|
134
|
-
const span = meta.editAnchors ?
|
|
135
|
-
const resultCount = splitLines(pipe.result).length;
|
|
136
|
-
const replacementCount = span ? resultCount - (pipe.originalHashes.length - (span[1] - span[0] + 1)) : 0;
|
|
133
|
+
const span = meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined;
|
|
137
134
|
let resultHashes: string[];
|
|
138
135
|
try {
|
|
139
|
-
resultHashes = pipe.result
|
|
140
|
-
|
|
141
|
-
:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
spans: span ? [{ start: span[0], end: span[1], replacementCount }] : undefined,
|
|
145
|
-
});
|
|
136
|
+
resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
|
|
137
|
+
content: pipe.originalNormalized,
|
|
138
|
+
hashes: pipe.originalHashes,
|
|
139
|
+
spans: span ? [span] : undefined,
|
|
140
|
+
});
|
|
146
141
|
} catch (error) {
|
|
147
142
|
const detail = error instanceof Error ? error.message : String(error);
|
|
148
143
|
throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
|
|
@@ -158,15 +153,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
158
153
|
editMeta,
|
|
159
154
|
boundaryDedupAbove: pipe.boundaryDedupAbove,
|
|
160
155
|
boundaryDedupBelow: pipe.boundaryDedupBelow,
|
|
161
|
-
...(span ? { spans: [
|
|
156
|
+
...(span ? { spans: [span] } : {}),
|
|
162
157
|
};
|
|
163
158
|
const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
|
|
164
159
|
if (changed.details.diff) {
|
|
165
|
-
|
|
166
|
-
mutationTargetPath,
|
|
167
|
-
buildServedMap(resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff)),
|
|
168
|
-
new Set(resultHashes),
|
|
169
|
-
);
|
|
160
|
+
serveRows(mutationTargetPath, resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff));
|
|
170
161
|
}
|
|
171
162
|
return changed;
|
|
172
163
|
}
|
package/src/config-ui.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { readConfig, type Config } from "./config";
|
|
4
4
|
|
|
5
|
-
export type ConfigToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -18,6 +18,7 @@ export interface ConfigRow {
|
|
|
18
18
|
export function configRows(config: Config): ConfigRow[] {
|
|
19
19
|
return [
|
|
20
20
|
{ key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
|
|
21
|
+
{ key: "autoReadAll", label: "Auto-read all", hint: "Attach files on the first turn: off, on, git (git repos only)", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "on", "git"] },
|
|
21
22
|
{ key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
|
|
22
23
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
23
24
|
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|
package/src/config.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { configPath } from "./paths";
|
|
|
4
4
|
import { errCode, isRec } from "./utils";
|
|
5
5
|
import { writeAtomic } from "./fs-write";
|
|
6
6
|
export type BoundaryDedupMode = "on" | "off" | "strict";
|
|
7
|
+
export type AutoReadAllMode = "off" | "on" | "git";
|
|
8
|
+
const AUTO_READ_ALL_MODES: AutoReadAllMode[] = ["off", "on", "git"];
|
|
7
9
|
|
|
8
10
|
export const DEFAULT_DIFF_CONTEXT_LINES = 1;
|
|
9
11
|
export const MIN_DIFF_CONTEXT_LINES = 0;
|
|
@@ -12,6 +14,7 @@ export const MAX_DIFF_CONTEXT_LINES = 10;
|
|
|
12
14
|
export interface Config {
|
|
13
15
|
autoRead: boolean;
|
|
14
16
|
anchorGrepEnabled: boolean;
|
|
17
|
+
autoReadAll?: AutoReadAllMode;
|
|
15
18
|
requirePath?: boolean;
|
|
16
19
|
strictInput?: boolean;
|
|
17
20
|
boundaryDedupMode?: BoundaryDedupMode;
|
|
@@ -21,6 +24,7 @@ export interface Config {
|
|
|
21
24
|
const DEFAULT_CONFIG: Config = {
|
|
22
25
|
autoRead: true,
|
|
23
26
|
anchorGrepEnabled: true,
|
|
27
|
+
autoReadAll: "off",
|
|
24
28
|
requirePath: false,
|
|
25
29
|
strictInput: false,
|
|
26
30
|
boundaryDedupMode: "on",
|
|
@@ -36,6 +40,13 @@ function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMo
|
|
|
36
40
|
return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
|
|
44
|
+
if (value === "off" || value === "on" || value === "git") return value;
|
|
45
|
+
if (value === true) return "on";
|
|
46
|
+
if (value === false) return "off";
|
|
47
|
+
return DEFAULT_CONFIG.autoReadAll ?? "off";
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
export function normalizeDiffContextLines(value: unknown): number {
|
|
40
51
|
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_DIFF_CONTEXT_LINES;
|
|
41
52
|
const floored = Math.floor(value);
|
|
@@ -50,15 +61,17 @@ function parseConfig(content: string): Config {
|
|
|
50
61
|
throw new Error("config.json must be an object with a boolean autoRead field");
|
|
51
62
|
}
|
|
52
63
|
const autoRead = parsed.autoRead;
|
|
53
|
-
const anchorGrepEnabled =
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
64
|
+
const anchorGrepEnabled = parsed.anchorGrepEnabled;
|
|
65
|
+
const autoReadAll = parsed.autoReadAll;
|
|
66
|
+
const requirePath = parsed.requirePath;
|
|
67
|
+
const strictInput = parsed.strictInput;
|
|
68
|
+
const boundaryDedupMode = parsed.boundaryDedupMode;
|
|
69
|
+
const legacyBoundaryDedup = parsed.boundaryDedupEnabled;
|
|
70
|
+
const diffContextLines = parsed.diffContextLines;
|
|
59
71
|
return {
|
|
60
72
|
autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
|
|
61
73
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
74
|
+
autoReadAll: parseAutoReadAllMode(autoReadAll),
|
|
62
75
|
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
63
76
|
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
64
77
|
boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
|
|
@@ -92,17 +105,23 @@ export async function readConfig(): Promise<Config> {
|
|
|
92
105
|
export async function readConfigWithStatus(): Promise<{ config: Config; corrupted: boolean }> {
|
|
93
106
|
return loadConfigFile();
|
|
94
107
|
}
|
|
95
|
-
const CONFIG_LOCK_RETRIES = 80;
|
|
96
108
|
const CONFIG_LOCK_DELAY_MS = 25;
|
|
97
109
|
const CONFIG_LOCK_STALE_MS = 5000;
|
|
98
|
-
|
|
110
|
+
const CONFIG_LOCK_RETRIES = Math.ceil(CONFIG_LOCK_STALE_MS / CONFIG_LOCK_DELAY_MS) * 2;
|
|
111
|
+
|
|
112
|
+
interface ConfigLock {
|
|
113
|
+
path: string;
|
|
114
|
+
dev: number;
|
|
115
|
+
ino: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function acquireConfigLock(lockPath: string): Promise<ConfigLock> {
|
|
99
119
|
try {
|
|
100
120
|
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
101
121
|
} catch { }
|
|
102
122
|
for (let attempt = 0; attempt < CONFIG_LOCK_RETRIES; attempt++) {
|
|
103
123
|
try {
|
|
104
124
|
await mkdir(lockPath, { mode: 0o700 });
|
|
105
|
-
return;
|
|
106
125
|
} catch (error) {
|
|
107
126
|
if (errCode(error) === "ENOENT") {
|
|
108
127
|
try {
|
|
@@ -119,26 +138,44 @@ async function acquireConfigLock(lockPath: string): Promise<void> {
|
|
|
119
138
|
}
|
|
120
139
|
} catch { }
|
|
121
140
|
await new Promise<void>((r) => setTimeout(r, CONFIG_LOCK_DELAY_MS));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const st = await stat(lockPath);
|
|
145
|
+
return { path: lockPath, dev: st.dev, ino: st.ino };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (errCode(error) !== "ENOENT") {
|
|
148
|
+
try {
|
|
149
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
150
|
+
} catch { }
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
122
153
|
}
|
|
123
154
|
}
|
|
124
155
|
throw new Error(`[E_ACCESS] Could not acquire config lock: ${lockPath}`);
|
|
125
156
|
}
|
|
126
|
-
async function releaseConfigLock(
|
|
157
|
+
async function releaseConfigLock(lock: ConfigLock): Promise<void> {
|
|
158
|
+
try {
|
|
159
|
+
const st = await stat(lock.path);
|
|
160
|
+
if (st.dev !== lock.dev || st.ino !== lock.ino) return;
|
|
161
|
+
} catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
127
164
|
try {
|
|
128
|
-
await rm(
|
|
165
|
+
await rm(lock.path, { recursive: true, force: true });
|
|
129
166
|
} catch { }
|
|
130
167
|
}
|
|
131
168
|
export async function updateConfig(mut: (config: Config) => void): Promise<Config> {
|
|
132
169
|
const cfgPath = configPath();
|
|
133
170
|
const lockPath = `${cfgPath}.lock`;
|
|
134
|
-
await acquireConfigLock(lockPath);
|
|
171
|
+
const lock = await acquireConfigLock(lockPath);
|
|
135
172
|
try {
|
|
136
173
|
const config = await readConfig();
|
|
137
174
|
mut(config);
|
|
138
175
|
await writeConfig(config);
|
|
139
176
|
return config;
|
|
140
177
|
} finally {
|
|
141
|
-
await releaseConfigLock(
|
|
178
|
+
await releaseConfigLock(lock);
|
|
142
179
|
}
|
|
143
180
|
}
|
|
144
181
|
export async function writeConfig(config: Config): Promise<void> {
|
|
@@ -146,22 +183,25 @@ export async function writeConfig(config: Config): Promise<void> {
|
|
|
146
183
|
}
|
|
147
184
|
|
|
148
185
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
export async function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
186
|
+
type ToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput";
|
|
187
|
+
|
|
188
|
+
async function toggleFlag(key: ToggleKey): Promise<boolean> {
|
|
189
|
+
const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
|
|
190
|
+
return config[key] === true;
|
|
191
|
+
}
|
|
192
|
+
export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
|
|
193
|
+
export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
|
|
194
|
+
export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
|
|
195
|
+
let next: AutoReadAllMode = "off";
|
|
196
|
+
await updateConfig((c) => {
|
|
197
|
+
const current = c.autoReadAll ?? "off";
|
|
198
|
+
next = AUTO_READ_ALL_MODES[(AUTO_READ_ALL_MODES.indexOf(current) + 1) % AUTO_READ_ALL_MODES.length] ?? "off";
|
|
199
|
+
c.autoReadAll = next;
|
|
200
|
+
});
|
|
201
|
+
return next;
|
|
164
202
|
}
|
|
203
|
+
export const toggleRequirePath = (): Promise<boolean> => toggleFlag("requirePath");
|
|
204
|
+
export const toggleStrictInput = (): Promise<boolean> => toggleFlag("strictInput");
|
|
165
205
|
export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
|
|
166
206
|
let next: BoundaryDedupMode = "on";
|
|
167
207
|
await updateConfig((c) => {
|
package/src/constants.ts
CHANGED
|
@@ -20,3 +20,9 @@ export const ANCHOR_POOL_EXHAUSTED_PREFIX =
|
|
|
20
20
|
"[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
|
|
21
21
|
|
|
22
22
|
export const DEDUP_ANCHOR = "dedup";
|
|
23
|
+
|
|
24
|
+
export const AUTO_READ_ALL_CUSTOM_TYPE = "hashline-auto-read-all";
|
|
25
|
+
export const AUTO_READ_ALL_MAX_FILES = 500;
|
|
26
|
+
export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;
|
|
27
|
+
export const AUTO_READ_ALL_MIN_BUDGET_BYTES = 200_000;
|
|
28
|
+
export const AUTO_READ_ALL_MAX_BUDGET_BYTES = 2_000_000;
|
package/src/edit-common.ts
CHANGED
|
@@ -45,6 +45,9 @@ export function withReplacePrompts(base: { description: string; snippet: string;
|
|
|
45
45
|
descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
|
|
46
46
|
snippetParts.push("; include `path` (required)");
|
|
47
47
|
guidelines.push("`replace`: include `path` matching the file the anchors were served for; it is required.");
|
|
48
|
+
} else {
|
|
49
|
+
descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
|
|
50
|
+
guidelines.push("`replace`: path resolution is anchor-only; don't pass `path`.");
|
|
48
51
|
}
|
|
49
52
|
if (flags.strictInput) {
|
|
50
53
|
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
@@ -74,6 +77,9 @@ export function withInsertPrompts(base: { description: string; snippet: string;
|
|
|
74
77
|
descriptionParts.push("Also give `path` matching the file the anchor was served for; it is required and must match anchor ownership.");
|
|
75
78
|
snippetParts.push("; include `path` (required)");
|
|
76
79
|
guidelines.push("`insert`: include `path` matching the file the anchor was served for; it is required.");
|
|
80
|
+
} else {
|
|
81
|
+
descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
|
|
82
|
+
guidelines.push("`insert`: path resolution is anchor-only; don't pass `path`.");
|
|
77
83
|
}
|
|
78
84
|
if (flags.strictInput) {
|
|
79
85
|
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
package/src/fs-write.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface FileIdentity {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function sameIdentity(
|
|
24
|
-
actual:
|
|
24
|
+
actual: FileIdentity,
|
|
25
25
|
expected: FileIdentity,
|
|
26
26
|
): boolean {
|
|
27
27
|
return actual.dev === expected.dev && actual.ino === expected.ino;
|