pi-hashline-edit-pro 1.1.0 → 1.1.2
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 +1 -1
- package/index.ts +8 -2
- package/package.json +5 -5
- package/prompts/replace-guidelines.md +4 -2
- package/src/file-kind.ts +17 -0
- package/src/file-reader.ts +10 -4
- package/src/hash-store.ts +70 -18
- package/src/hashline/hash.ts +18 -5
- package/src/read.ts +4 -4
- package/src/replace-response.ts +2 -2
- package/src/replace.ts +12 -3
- package/src/validation.ts +3 -0
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
199
199
|
|
|
200
200
|
## Development
|
|
201
201
|
|
|
202
|
-
Requires [Node.js](https://nodejs.org) ≥ 22.
|
|
202
|
+
Requires [Node.js](https://nodejs.org) ≥ 22.19 and npm.
|
|
203
203
|
|
|
204
204
|
```bash
|
|
205
205
|
npm install
|
package/index.ts
CHANGED
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
} from "./src/config";
|
|
14
14
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
15
15
|
import { readNormFile } from "./src/file-reader";
|
|
16
|
+
import { loadFileKindAndText } from "./src/file-kind";
|
|
16
17
|
import { toCwd } from "./src/paths";
|
|
17
18
|
import { resolveTarget } from "./src/fs-write";
|
|
19
|
+
import { valAccess } from "./src/validation";
|
|
18
20
|
|
|
19
21
|
export default function (pi: ExtensionAPI): void {
|
|
20
22
|
regRead(pi);
|
|
@@ -22,7 +24,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
22
24
|
regReplace(pi);
|
|
23
25
|
regReplaceUndo(pi);
|
|
24
26
|
|
|
25
|
-
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
26
27
|
let autoRead = true;
|
|
27
28
|
|
|
28
29
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -37,6 +38,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
37
38
|
}
|
|
38
39
|
const config = await readConfig();
|
|
39
40
|
autoRead = config.autoRead;
|
|
41
|
+
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
40
42
|
if (debugValue === "1" || debugValue === "true") {
|
|
41
43
|
ctx.ui.notify(`Hashline Edit mode active`, "info");
|
|
42
44
|
}
|
|
@@ -66,8 +68,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
66
68
|
if (!autoRead) return;
|
|
67
69
|
if (typeof writtenPath !== "string") return;
|
|
68
70
|
try {
|
|
71
|
+
const resolvedPath = await resolveTarget(toCwd(writtenPath, ctx.cwd));
|
|
72
|
+
await valAccess(resolvedPath, writtenPath);
|
|
73
|
+
const file = await loadFileKindAndText(resolvedPath, { maxLines: MAX_HASH_LINES, displayPath: writtenPath });
|
|
74
|
+
if (file.kind !== "text") return;
|
|
69
75
|
const { normalized, fileHashes, absolutePath } = await readNormFile(
|
|
70
|
-
writtenPath, ctx.cwd, { maxLines: MAX_HASH_LINES },
|
|
76
|
+
writtenPath, ctx.cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file },
|
|
71
77
|
);
|
|
72
78
|
const preview = await fmtReadPreview(
|
|
73
79
|
normalized,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -34,15 +34,15 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"diff": "^8.0.2",
|
|
36
36
|
"file-type": "^21.3.0",
|
|
37
|
-
"typebox": "^1.
|
|
37
|
+
"typebox": "^1.3.7",
|
|
38
38
|
"xxhash-wasm": "^1.1.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
41
|
+
"@earendil-works/pi-coding-agent": ">=0.75.0",
|
|
42
42
|
"@earendil-works/pi-tui": "*"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
45
|
-
"node": ">=22.
|
|
45
|
+
"node": ">=22.19.0"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"test": "vitest run",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"typecheck": "tsc --noEmit"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
56
56
|
"@eslint/js": "^10.0.1",
|
|
57
57
|
"@types/node": "^24.0.0",
|
|
58
58
|
"@vitest/coverage-v8": "^4.1.10",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
- `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file.
|
|
2
|
+
- `replace`: hash_range_inclusive marks the exact lines that are REMOVED, and content_lines is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in content_lines is deleted from the file — including closing braces and other structural lines.
|
|
3
|
+
- `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
|
|
4
|
+
- `replace`: to replace a single line, repeat its hash in both positions of hash_range_inclusive: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
|
|
2
5
|
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
3
|
-
- `replace`:
|
|
4
|
-
- `replace`: content_lines entries are single lines — never embed a line break inside an entry; pass each line as its own array entry.
|
|
6
|
+
- `replace`: content_lines entries are single lines — never embed a line break inside an entry; pass each line as its own array entry.
|
package/src/file-kind.ts
CHANGED
|
@@ -46,8 +46,14 @@ export type LFile =
|
|
|
46
46
|
| { kind: "binary"; description: string };
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
export interface LoadFileOptions {
|
|
50
|
+
maxLines?: number;
|
|
51
|
+
displayPath?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
export async function loadFileKindAndText(
|
|
50
55
|
filePath: string,
|
|
56
|
+
options?: LoadFileOptions,
|
|
51
57
|
): Promise<LFile> {
|
|
52
58
|
const pathStat = await fsStat(filePath);
|
|
53
59
|
if (pathStat.isDirectory()) {
|
|
@@ -104,6 +110,7 @@ export async function loadFileKindAndText(
|
|
|
104
110
|
|
|
105
111
|
const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
|
|
106
112
|
let hadUtf8DecodeErrors = false;
|
|
113
|
+
let newlineCount = 0;
|
|
107
114
|
const parts: string[] = [];
|
|
108
115
|
|
|
109
116
|
function decodeChunk(chunk: Uint8Array, stream: boolean): string {
|
|
@@ -111,6 +118,16 @@ export async function loadFileKindAndText(
|
|
|
111
118
|
if (!hadUtf8DecodeErrors && decoded.includes("\uFFFD")) {
|
|
112
119
|
hadUtf8DecodeErrors = true;
|
|
113
120
|
}
|
|
121
|
+
if (options?.maxLines !== undefined) {
|
|
122
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
123
|
+
if (decoded.charCodeAt(i) === 10) newlineCount++;
|
|
124
|
+
}
|
|
125
|
+
if (newlineCount > options.maxLines) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[E_FILE_TOO_LARGE] ${options.displayPath ?? filePath} has more than ${options.maxLines} lines, exceeding the ${options.maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
114
131
|
return decoded;
|
|
115
132
|
}
|
|
116
133
|
|
package/src/file-reader.ts
CHANGED
|
@@ -20,12 +20,17 @@ export interface NormFile {
|
|
|
20
20
|
|
|
21
21
|
export type SnapInfo = {
|
|
22
22
|
snapshotId: string;
|
|
23
|
+
ino: number;
|
|
23
24
|
mtimeMs: number;
|
|
25
|
+
ctimeMs: number;
|
|
24
26
|
size: number;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
|
-
function fmtSnapId(
|
|
28
|
-
|
|
29
|
+
function fmtSnapId(
|
|
30
|
+
canonicalPath: string,
|
|
31
|
+
info: { ino: number; mtimeMs: number; ctimeMs: number; size: number },
|
|
32
|
+
): string {
|
|
33
|
+
return `v2|${canonicalPath}|${info.ino}|${info.mtimeMs}|${info.ctimeMs}|${info.size}`;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
|
|
@@ -33,7 +38,9 @@ export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
|
|
|
33
38
|
const stats = await stat(canonicalPath);
|
|
34
39
|
return {
|
|
35
40
|
snapshotId: fmtSnapId(canonicalPath, stats),
|
|
41
|
+
ino: stats.ino,
|
|
36
42
|
mtimeMs: stats.mtimeMs,
|
|
43
|
+
ctimeMs: stats.ctimeMs,
|
|
37
44
|
size: stats.size,
|
|
38
45
|
};
|
|
39
46
|
}
|
|
@@ -60,9 +67,8 @@ export async function readNormFile(
|
|
|
60
67
|
await valAccess(resolvedPath, path, accessMode);
|
|
61
68
|
|
|
62
69
|
abortIf(signal);
|
|
63
|
-
const file = options?.preloadedFile ?? (await loadFileKindAndText(resolvedPath));
|
|
70
|
+
const file = options?.preloadedFile ?? (await loadFileKindAndText(resolvedPath, { maxLines: options?.maxLines, displayPath: path }));
|
|
64
71
|
valKind(file, path);
|
|
65
|
-
|
|
66
72
|
abortIf(signal);
|
|
67
73
|
const { bom, text: rawContent } = stripBOM(file.text);
|
|
68
74
|
const originalEnding = detectEnding(rawContent);
|
package/src/hash-store.ts
CHANGED
|
@@ -46,6 +46,55 @@ function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
|
46
46
|
return true;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function isCorruptionError(error: unknown): boolean {
|
|
50
|
+
if (error && typeof error === "object") {
|
|
51
|
+
const errcode = (error as { errcode?: unknown }).errcode;
|
|
52
|
+
if (typeof errcode === "number") {
|
|
53
|
+
return errcode === 11 || errcode === 24 || errcode === 26;
|
|
54
|
+
}
|
|
55
|
+
const code = (error as { code?: unknown }).code;
|
|
56
|
+
if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
error instanceof Error &&
|
|
60
|
+
/corrupt|not a database|malformed|database disk image/i.test(error.message)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isBusyError(error: unknown): boolean {
|
|
65
|
+
if (error && typeof error === "object") {
|
|
66
|
+
const errcode = (error as { errcode?: unknown }).errcode;
|
|
67
|
+
if (typeof errcode === "number") return errcode === 5 || errcode === 6;
|
|
68
|
+
}
|
|
69
|
+
return error instanceof Error && /busy|locked/i.test(error.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sleepSync(ms: number): void {
|
|
73
|
+
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
74
|
+
Atomics.wait(sab, 0, 0, ms);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const BUSY_RETRIES = 3;
|
|
78
|
+
const BUSY_RETRY_DELAY_MS = 100;
|
|
79
|
+
|
|
80
|
+
function withBusyRetry<T>(fn: () => T): T {
|
|
81
|
+
let lastError: unknown;
|
|
82
|
+
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
return fn();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
lastError = error;
|
|
87
|
+
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
88
|
+
sleepSync(BUSY_RETRY_DELAY_MS);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw lastError;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function openDbWithBusyRetry(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
95
|
+
return withBusyRetry(() => openDb(storePath));
|
|
96
|
+
}
|
|
97
|
+
|
|
49
98
|
let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
|
|
50
99
|
let opening: { path: string; promise: Promise<HashStore> } | null = null;
|
|
51
100
|
let exitHandlerRegistered = false;
|
|
@@ -121,13 +170,12 @@ function buildStore(
|
|
|
121
170
|
const stmts: Prepared = {
|
|
122
171
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
123
172
|
allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
|
|
124
|
-
deleteOne: (...params) => { delStmt.run(...params); },
|
|
125
|
-
upsert: (...params) => { upsertStmt.run(...params); },
|
|
126
|
-
undoUpsert: (...params) => { undoUpsertStmt.run(...params); },
|
|
173
|
+
deleteOne: (...params) => { withBusyRetry(() => { delStmt.run(...params); }); },
|
|
174
|
+
upsert: (...params) => { withBusyRetry(() => { upsertStmt.run(...params); }); },
|
|
175
|
+
undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
|
|
127
176
|
undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
128
|
-
undoDelete: (...params) => { undoDelStmt.run(...params); },
|
|
177
|
+
undoDelete: (...params) => { withBusyRetry(() => { undoDelStmt.run(...params); }); },
|
|
129
178
|
};
|
|
130
|
-
|
|
131
179
|
return { db, stmts };
|
|
132
180
|
}
|
|
133
181
|
|
|
@@ -135,8 +183,9 @@ function isHealthy(db: DatabaseSync): boolean {
|
|
|
135
183
|
try {
|
|
136
184
|
const row = db.prepare("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
|
|
137
185
|
return row?.quick_check === "ok";
|
|
138
|
-
} catch {
|
|
139
|
-
return false;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (isCorruptionError(error)) return false;
|
|
188
|
+
return true;
|
|
140
189
|
}
|
|
141
190
|
}
|
|
142
191
|
|
|
@@ -170,18 +219,19 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
170
219
|
let existed = existsSync(storePath);
|
|
171
220
|
let opened: { db: DatabaseSync; stmts: Prepared };
|
|
172
221
|
try {
|
|
173
|
-
opened =
|
|
222
|
+
opened = openDbWithBusyRetry(storePath);
|
|
174
223
|
} catch (error) {
|
|
224
|
+
if (!isCorruptionError(error)) throw error;
|
|
175
225
|
console.error("Hash store failed to open, rebuilding:", error);
|
|
176
226
|
await quarantineStore(storePath);
|
|
177
227
|
existed = false;
|
|
178
|
-
opened =
|
|
228
|
+
opened = openDbWithBusyRetry(storePath);
|
|
179
229
|
}
|
|
180
230
|
if (!isHealthy(opened.db)) {
|
|
181
231
|
shutdownDb(opened.db);
|
|
182
232
|
await quarantineStore(storePath);
|
|
183
233
|
existed = false;
|
|
184
|
-
opened =
|
|
234
|
+
opened = openDbWithBusyRetry(storePath);
|
|
185
235
|
}
|
|
186
236
|
const { db, stmts } = opened;
|
|
187
237
|
|
|
@@ -228,14 +278,16 @@ export function shutdownHashStore(): void {
|
|
|
228
278
|
|
|
229
279
|
function withStore(fn: () => void): void {
|
|
230
280
|
if (cachedDb) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
281
|
+
withBusyRetry(() => {
|
|
282
|
+
cachedDb!.db.exec("BEGIN IMMEDIATE");
|
|
283
|
+
try {
|
|
284
|
+
fn();
|
|
285
|
+
cachedDb!.db.exec("COMMIT");
|
|
286
|
+
} catch (e) {
|
|
287
|
+
try { cachedDb!.db.exec("ROLLBACK"); } catch {}
|
|
288
|
+
throw e;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
239
291
|
} else {
|
|
240
292
|
fn();
|
|
241
293
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -43,10 +43,10 @@ function hashAt(idx: number): string {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
46
|
-
|
|
46
|
+
`^\\+${HASH_CLASS}│`,
|
|
47
47
|
);
|
|
48
48
|
export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
49
|
-
`^-(
|
|
49
|
+
`^-(?:${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
|
|
50
50
|
);
|
|
51
51
|
|
|
52
52
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
|
|
@@ -157,19 +157,32 @@ export async function lineHashes(
|
|
|
157
157
|
previous.removedHashes,
|
|
158
158
|
);
|
|
159
159
|
if (persist !== false) {
|
|
160
|
-
|
|
160
|
+
try {
|
|
161
|
+
upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error("Failed to persist hash snapshot:", error);
|
|
164
|
+
}
|
|
161
165
|
}
|
|
162
166
|
return newHashes;
|
|
163
167
|
}
|
|
164
168
|
|
|
165
|
-
|
|
169
|
+
let cached: string[] | undefined;
|
|
170
|
+
try {
|
|
171
|
+
cached = getSnapshot(hashStore, path, content);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error("Failed to read hash store snapshot:", error);
|
|
174
|
+
}
|
|
166
175
|
if (cached) {
|
|
167
176
|
return cached;
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
const newHashes = _lineHashesPure(content);
|
|
171
180
|
if (persist !== false) {
|
|
172
|
-
|
|
181
|
+
try {
|
|
182
|
+
upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error("Failed to persist hash snapshot:", error);
|
|
185
|
+
}
|
|
173
186
|
}
|
|
174
187
|
return newHashes;
|
|
175
188
|
}
|
package/src/read.ts
CHANGED
|
@@ -100,15 +100,15 @@ export async function fmtReadPreview(
|
|
|
100
100
|
: fmtRegion([selectedHashes[index]!], [selected[index]!]),
|
|
101
101
|
);
|
|
102
102
|
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
|
|
103
|
-
const
|
|
104
|
-
const lastShownLine =
|
|
103
|
+
const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
|
|
104
|
+
const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
|
|
105
105
|
const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
|
|
106
106
|
const verb = oversized.length === 1 ? "exceeds" : "exceed";
|
|
107
107
|
const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
|
|
108
108
|
const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown because hashline anchors require full lines. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
|
|
109
109
|
let preview = skippedTruncation.content;
|
|
110
110
|
let nextOffset: number | undefined;
|
|
111
|
-
if (
|
|
111
|
+
if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
|
|
112
112
|
nextOffset = lastShownLine + 1;
|
|
113
113
|
preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
|
|
114
114
|
} else {
|
|
@@ -178,7 +178,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
178
178
|
await valAccess(absolutePath, rawPath);
|
|
179
179
|
|
|
180
180
|
abortIf(signal);
|
|
181
|
-
const file = await loadFileKindAndText(absolutePath);
|
|
181
|
+
const file = await loadFileKindAndText(absolutePath, { maxLines: MAX_HASH_LINES, displayPath: rawPath });
|
|
182
182
|
if (file.kind === "image") {
|
|
183
183
|
const builtinRead = createReadTool(ctx.cwd);
|
|
184
184
|
const executeBuiltinRead = builtinRead.execute as unknown as (
|
package/src/replace-response.ts
CHANGED
|
@@ -35,7 +35,7 @@ type NEditEntry = {
|
|
|
35
35
|
export interface NoopInput {
|
|
36
36
|
path: string;
|
|
37
37
|
noopEdit: NEditEntry | undefined;
|
|
38
|
-
snapshotId
|
|
38
|
+
snapshotId?: string;
|
|
39
39
|
editMeta: RMeta;
|
|
40
40
|
warnings: string[] | undefined;
|
|
41
41
|
}
|
|
@@ -47,7 +47,7 @@ export interface SuccessInput {
|
|
|
47
47
|
result: string;
|
|
48
48
|
resultHashes: string[];
|
|
49
49
|
warnings: string[] | undefined;
|
|
50
|
-
snapshotId
|
|
50
|
+
snapshotId?: string;
|
|
51
51
|
editMeta: RMeta;
|
|
52
52
|
}
|
|
53
53
|
|
package/src/replace.ts
CHANGED
|
@@ -453,7 +453,12 @@ export function buildToolDef(): ToolDef {
|
|
|
453
453
|
|
|
454
454
|
const editsAttempted = 1;
|
|
455
455
|
if (originalNormalized === result) {
|
|
456
|
-
|
|
456
|
+
let noopSnapshotId: string | undefined;
|
|
457
|
+
try {
|
|
458
|
+
noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
459
|
+
} catch (error) {
|
|
460
|
+
console.error("Failed to compute snapshot for noop edit:", error);
|
|
461
|
+
}
|
|
457
462
|
return buildNoop({
|
|
458
463
|
path,
|
|
459
464
|
noopEdit,
|
|
@@ -497,8 +502,12 @@ export function buildToolDef(): ToolDef {
|
|
|
497
502
|
await undo.restore();
|
|
498
503
|
throw error;
|
|
499
504
|
}
|
|
500
|
-
|
|
501
|
-
|
|
505
|
+
let updatedSnapshotId: string | undefined;
|
|
506
|
+
try {
|
|
507
|
+
updatedSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.error("Failed to compute post-edit snapshot:", error);
|
|
510
|
+
}
|
|
502
511
|
|
|
503
512
|
const editMeta: RMeta = {
|
|
504
513
|
editsAttempted,
|
package/src/validation.ts
CHANGED
|
@@ -19,6 +19,9 @@ export async function valAccess(
|
|
|
19
19
|
const accessLabel = accessMode & constants.W_OK ? "not writable" : "not readable";
|
|
20
20
|
throw new Error(`[E_ACCESS] File is ${accessLabel}: ${path}`);
|
|
21
21
|
}
|
|
22
|
+
if (code === "ELOOP") {
|
|
23
|
+
throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
|
|
24
|
+
}
|
|
22
25
|
throw new Error(`[E_ACCESS] Cannot access file: ${path}`);
|
|
23
26
|
}
|
|
24
27
|
}
|