pi-hashline-edit-pro 0.9.3 → 0.9.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 +2 -1
- package/index.ts +5 -14
- package/package.json +1 -1
- package/src/constants.ts +5 -0
- package/src/file-kind.ts +2 -9
- package/src/hashline/apply.ts +30 -11
- package/src/hashline/hash.ts +1 -11
- package/src/hashline/index.ts +1 -0
- package/src/hashline/resolve.ts +36 -30
- package/src/prompts.ts +19 -0
- package/src/read.ts +12 -61
- package/src/replace-diff.ts +2 -5
- package/src/replace-normalize.ts +1 -5
- package/src/replace-render.ts +1 -22
- package/src/replace-response.ts +4 -10
- package/src/replace.ts +8 -60
- package/src/snapshot.ts +0 -7
- package/src/utils.ts +0 -9
- package/src/validation.ts +46 -0
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
79
79
|
- **Request structure validation.** The request envelope (`path`, `edits`) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
|
|
80
80
|
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
|
|
81
81
|
|
|
82
|
-
All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so
|
|
82
|
+
All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
|
|
83
83
|
|
|
84
84
|
### Chained edits
|
|
85
85
|
|
|
@@ -108,6 +108,7 @@ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details
|
|
|
108
108
|
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
|
|
109
109
|
- **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
|
|
110
110
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
|
111
|
+
- **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning includes the surviving line's post-edit hash so the model can reference it in a follow-up edit. No autocorrection is applied — the duplicate stays in the file and the model decides whether to remove it. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
|
111
112
|
|
|
112
113
|
## Hashing
|
|
113
114
|
|
package/index.ts
CHANGED
|
@@ -6,23 +6,20 @@ import { registerReplaceTool } from "./src/replace";
|
|
|
6
6
|
import { registerReadTool } from "./src/read";
|
|
7
7
|
import { normalizeToLF } from "./src/replace-diff";
|
|
8
8
|
import { getVisibleLines } from "./src/utils";
|
|
9
|
+
import { AUTO_READ_MAX_LINES } from "./src/constants";
|
|
9
10
|
|
|
10
11
|
export default function (pi: ExtensionAPI): void {
|
|
11
12
|
registerReadTool(pi);
|
|
12
13
|
registerReplaceTool(pi);
|
|
13
14
|
|
|
14
|
-
// Disable the built-in `edit` tool so the model uses `replace` instead.
|
|
15
15
|
pi.on("session_start", async (_event, ctx) => {
|
|
16
16
|
const active = pi.getActiveTools();
|
|
17
17
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
// Auto-read after write state - controlled by PI_HASHLINE_AUTO_READ env var (default: disabled).
|
|
21
|
-
// Can be toggled at runtime via /toggle-auto-read command.
|
|
22
20
|
const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
|
|
23
21
|
let autoReadEnabled = autoReadValue === "1" || autoReadValue === "true";
|
|
24
22
|
|
|
25
|
-
// Register toggle-auto-read command
|
|
26
23
|
pi.registerCommand("toggle-auto-read", {
|
|
27
24
|
description: "Toggle automatic hashline anchors after write operations",
|
|
28
25
|
handler: async (_args, ctx) => {
|
|
@@ -32,7 +29,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
32
29
|
},
|
|
33
30
|
});
|
|
34
31
|
|
|
35
|
-
// Auto-read after write handler - always registered, but checks the flag
|
|
36
32
|
pi.on("tool_result", async (event, ctx) => {
|
|
37
33
|
if (!autoReadEnabled) return;
|
|
38
34
|
if (event.toolName !== "write" || event.isError) return;
|
|
@@ -44,24 +40,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
44
40
|
const absolutePath = isAbsolute(filePath) ? filePath : join(ctx.cwd, filePath);
|
|
45
41
|
const content = await readFile(absolutePath, "utf-8");
|
|
46
42
|
|
|
47
|
-
// Normalize and compute hashline output
|
|
48
43
|
const normalized = normalizeToLF(content);
|
|
49
44
|
const visibleLines = getVisibleLines(normalized);
|
|
50
45
|
|
|
51
46
|
if (visibleLines.length === 0) return;
|
|
52
47
|
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const truncated = visibleLines.length > MAX_LINES;
|
|
56
|
-
const displayLines = truncated ? visibleLines.slice(0, MAX_LINES) : visibleLines;
|
|
48
|
+
const truncated = visibleLines.length > AUTO_READ_MAX_LINES;
|
|
49
|
+
const displayLines = truncated ? visibleLines.slice(0, AUTO_READ_MAX_LINES) : visibleLines;
|
|
57
50
|
|
|
58
51
|
const hashes = computeLineHashes(normalized);
|
|
59
52
|
const selectedHashes = hashes.slice(0, displayLines.length);
|
|
60
53
|
const hashlineOutput = formatHashlineRegion(selectedHashes, displayLines);
|
|
61
54
|
|
|
62
|
-
// Add pagination hint if truncated
|
|
63
55
|
const paginationHint = truncated
|
|
64
|
-
? `\n\n[Showing lines 1-${
|
|
56
|
+
? `\n\n[Showing lines 1-${AUTO_READ_MAX_LINES} of ${visibleLines.length}. Use offset=${AUTO_READ_MAX_LINES + 1} to continue.]`
|
|
65
57
|
: "";
|
|
66
58
|
|
|
67
59
|
if (hashlineOutput) {
|
|
@@ -73,7 +65,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
73
65
|
};
|
|
74
66
|
}
|
|
75
67
|
} catch {
|
|
76
|
-
// Auto-read failure should not affect write result
|
|
77
68
|
}
|
|
78
69
|
});
|
|
79
70
|
|
|
@@ -83,4 +74,4 @@ export default function (pi: ExtensionAPI): void {
|
|
|
83
74
|
ctx.ui.notify("Hashline Edit mode active", "info");
|
|
84
75
|
});
|
|
85
76
|
}
|
|
86
|
-
}
|
|
77
|
+
}
|
package/package.json
CHANGED
package/src/constants.ts
ADDED
package/src/file-kind.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { open as fsOpen, stat as fsStat } from "fs/promises";
|
|
2
2
|
import { fileTypeFromBuffer } from "file-type";
|
|
3
|
+
import { FILE_TYPE_SNIFF_BYTES } from "./constants";
|
|
3
4
|
|
|
4
5
|
const IMAGE_MIME_TYPES = new Set<string>([
|
|
5
6
|
"image/jpeg",
|
|
@@ -18,8 +19,6 @@ function isTextLikeMimeType(mimeType: string): boolean {
|
|
|
18
19
|
return mimeType.startsWith("text/") || TEXT_LIKE_MIME_TYPES.has(mimeType);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const FILE_TYPE_SNIFF_BYTES = 8192;
|
|
22
|
-
|
|
23
22
|
export type FileKind =
|
|
24
23
|
| { kind: "directory" }
|
|
25
24
|
| { kind: "image"; mimeType: string }
|
|
@@ -84,12 +83,6 @@ export async function loadFileKindAndText(
|
|
|
84
83
|
};
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
// Non-fatal decode, matching pi's built-in tools: invalid UTF-8 becomes
|
|
88
|
-
// U+FFFD rather than rejecting the file. The null-byte guard above is the
|
|
89
|
-
// only signal we treat as binary, so non-UTF-8 text (CP1251, GBK, …) reads
|
|
90
|
-
// instead of forcing the model to bypass hashline with raw shell edits.
|
|
91
|
-
// Track fatal-decoder failures separately so a literal, valid U+FFFD in a
|
|
92
|
-
// UTF-8 file does not get mistaken for lossy decoding.
|
|
93
86
|
const decoder = new TextDecoder("utf-8");
|
|
94
87
|
const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
95
88
|
let hadUtf8DecodeErrors = false;
|
|
@@ -158,4 +151,4 @@ export async function classifyFileKind(filePath: string): Promise<FileKind> {
|
|
|
158
151
|
case "text":
|
|
159
152
|
return { kind: "text" };
|
|
160
153
|
}
|
|
161
|
-
}
|
|
154
|
+
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import { throwIfAborted } from "../runtime";
|
|
3
2
|
import { computeLineHashes } from "./hash";
|
|
4
3
|
import {
|
|
@@ -10,9 +9,10 @@ import {
|
|
|
10
9
|
type ResolvedHashlineEdit,
|
|
11
10
|
type NoopEdit,
|
|
12
11
|
type HashlineEdit,
|
|
12
|
+
type BoundaryDuplicationWarning,
|
|
13
13
|
} from "./resolve";
|
|
14
14
|
import { countVisibleLines } from "../utils";
|
|
15
|
-
|
|
15
|
+
import { ANCHOR_CONTEXT_LINES, ANCHOR_MAX_OUTPUT_LINES } from "../constants";
|
|
16
16
|
|
|
17
17
|
type LineIndex = {
|
|
18
18
|
fileLines: string[];
|
|
@@ -235,7 +235,6 @@ export function applyHashlineEdits(
|
|
|
235
235
|
lastChangedLine: undefined,
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
-
// Normalize new_lines: [""] to new_lines: [] for deletion.
|
|
239
238
|
edits = edits.map((edit) =>
|
|
240
239
|
edit.new_lines.length === 1 &&
|
|
241
240
|
edit.new_lines[0] === ""
|
|
@@ -248,7 +247,7 @@ export function applyHashlineEdits(
|
|
|
248
247
|
const noopEdits: NoopEdit[] = [];
|
|
249
248
|
const warnings: string[] = [];
|
|
250
249
|
|
|
251
|
-
const { resolved, mismatches } = validateAnchorEdits(
|
|
250
|
+
const { resolved, mismatches, boundaryWarnings } = validateAnchorEdits(
|
|
252
251
|
edits,
|
|
253
252
|
lineIndex.fileLines,
|
|
254
253
|
fileHashes,
|
|
@@ -277,6 +276,31 @@ export function applyHashlineEdits(
|
|
|
277
276
|
assertDoesNotEmptyFile(content, result);
|
|
278
277
|
const changedRange = computeChangedLineRange(content, result);
|
|
279
278
|
|
|
279
|
+
const resultLines = result.split("\n");
|
|
280
|
+
const resultHashes = computeLineHashes(result);
|
|
281
|
+
for (const bw of boundaryWarnings) {
|
|
282
|
+
let seen = 0;
|
|
283
|
+
let matchIndex = -1;
|
|
284
|
+
for (let i = 0; i < resultLines.length; i++) {
|
|
285
|
+
if (resultLines[i] === bw.survivingLineContent) {
|
|
286
|
+
if (seen === bw.occurrence) { matchIndex = i; break; }
|
|
287
|
+
seen++;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (matchIndex >= 0) {
|
|
291
|
+
const hash = resultHashes[matchIndex];
|
|
292
|
+
if (bw.kind === "trailing") {
|
|
293
|
+
warnings.push(
|
|
294
|
+
`Potential boundary duplication: the last line of the replacement (${JSON.stringify(bw.replacementLineContent)}) matches the next surviving line. Surviving line hash: ${hash}`
|
|
295
|
+
);
|
|
296
|
+
} else {
|
|
297
|
+
warnings.push(
|
|
298
|
+
`Potential boundary duplication: the first line of the replacement (${JSON.stringify(bw.replacementLineContent)}) matches the preceding surviving line. Surviving line hash: ${hash}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
280
304
|
return {
|
|
281
305
|
content: result,
|
|
282
306
|
firstChangedLine: changedRange?.firstChangedLine,
|
|
@@ -287,9 +311,6 @@ export function applyHashlineEdits(
|
|
|
287
311
|
}
|
|
288
312
|
|
|
289
313
|
|
|
290
|
-
const ANCHOR_CONTEXT_LINES = 0;
|
|
291
|
-
const ANCHOR_MAX_OUTPUT_LINES = 12;
|
|
292
|
-
|
|
293
314
|
export function computeAffectedLineRange(params: {
|
|
294
315
|
firstChangedLine: number | undefined;
|
|
295
316
|
lastChangedLine: number | undefined;
|
|
@@ -309,8 +330,6 @@ export function computeAffectedLineRange(params: {
|
|
|
309
330
|
return null;
|
|
310
331
|
}
|
|
311
332
|
|
|
312
|
-
// When contextLines is 0, skip the anchor block entirely.
|
|
313
|
-
// The LLM already knows what it changed and can call read for fresh anchors.
|
|
314
333
|
if (contextLines === 0) {
|
|
315
334
|
return null;
|
|
316
335
|
}
|
|
@@ -376,6 +395,7 @@ export function computeChangedLineRange(
|
|
|
376
395
|
}
|
|
377
396
|
if (firstDiff === minLen && original.length === result.length) return null;
|
|
378
397
|
|
|
398
|
+
|
|
379
399
|
let lastOrig = original.length - 1;
|
|
380
400
|
let lastRes = result.length - 1;
|
|
381
401
|
while (
|
|
@@ -410,5 +430,4 @@ export function computeChangedLineRange(
|
|
|
410
430
|
}
|
|
411
431
|
|
|
412
432
|
return { firstChangedLine, lastChangedLine };
|
|
413
|
-
}
|
|
414
|
-
|
|
433
|
+
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
|
|
2
1
|
import xxhash from "xxhash-wasm";
|
|
3
2
|
|
|
4
3
|
|
|
5
4
|
export const HASH_LENGTH = 3;
|
|
6
|
-
|
|
7
5
|
export const HASH_PREFIX = "";
|
|
8
|
-
|
|
9
6
|
export const ANCHOR_LENGTH = HASH_LENGTH;
|
|
10
7
|
|
|
11
8
|
const HASH_ALPHABET =
|
|
@@ -42,27 +39,20 @@ export const HASHLINE_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CHARS_CLASS})│
|
|
|
42
39
|
|
|
43
40
|
|
|
44
41
|
|
|
45
|
-
// Lazy-initialized xxhash-wasm hasher. Initialization starts at module load
|
|
46
|
-
// time and completes in ~2ms. By the time any tool calls xxh32(), the hasher
|
|
47
|
-
// is ready.
|
|
48
42
|
type Hasher = { h32(input: string, seed?: number): number };
|
|
49
43
|
let hasherPromise: Promise<Hasher> | null = null;
|
|
50
44
|
let hasherSync: Hasher | null = null;
|
|
51
45
|
|
|
52
46
|
function getHasher(): Hasher {
|
|
53
47
|
if (hasherSync) return hasherSync;
|
|
54
|
-
// Fast path won't hit this in practice — the wasm init completes in ~2ms
|
|
55
|
-
// and no tool call happens at import time. But if it does, throw clearly.
|
|
56
48
|
throw new Error("xxhash-wasm not initialized yet. This should not happen.");
|
|
57
49
|
}
|
|
58
50
|
|
|
59
|
-
// Start initialization immediately at module load time.
|
|
60
51
|
hasherPromise = xxhash().then((h) => {
|
|
61
52
|
hasherSync = h;
|
|
62
53
|
return h;
|
|
63
54
|
});
|
|
64
55
|
|
|
65
|
-
// Export for tests that need to await readiness.
|
|
66
56
|
export function ensureHasherReady(): Promise<Hasher> {
|
|
67
57
|
return hasherPromise!
|
|
68
58
|
}
|
|
@@ -108,4 +98,4 @@ export const HASH_FORMAT = {
|
|
|
108
98
|
};
|
|
109
99
|
|
|
110
100
|
|
|
111
|
-
export { HASH_ALPHABET_RE };
|
|
101
|
+
export { HASH_ALPHABET_RE };
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -21,6 +21,15 @@ interface HashMismatch {
|
|
|
21
21
|
candidates?: number[];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface BoundaryDuplicationWarning {
|
|
25
|
+
kind: "trailing" | "leading";
|
|
26
|
+
survivingLineContent: string;
|
|
27
|
+
survivingLineIndex: number;
|
|
28
|
+
occurrence: number;
|
|
29
|
+
replacementLineContent: string;
|
|
30
|
+
editIndex: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
export interface NoopEdit {
|
|
25
34
|
editIndex: number;
|
|
26
35
|
loc: string;
|
|
@@ -30,9 +39,7 @@ export interface NoopEdit {
|
|
|
30
39
|
export type HashlineToolEdit = {
|
|
31
40
|
old_range?: [string, string];
|
|
32
41
|
new_lines?: string[];
|
|
33
|
-
/** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
|
|
34
42
|
oldText?: string;
|
|
35
|
-
/** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
|
|
36
43
|
newText?: string;
|
|
37
44
|
};
|
|
38
45
|
|
|
@@ -155,7 +162,6 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
|
155
162
|
for (const [index, edit] of edits.entries()) {
|
|
156
163
|
assertEditItem(edit as Record<string, unknown>, index);
|
|
157
164
|
|
|
158
|
-
// Normalize new_lines: [""] to new_lines: [] for deletion.
|
|
159
165
|
const replaceLines = hashlineParseText(edit.new_lines ?? null);
|
|
160
166
|
const normalizedLines =
|
|
161
167
|
replaceLines.length === 1 && replaceLines[0] === ""
|
|
@@ -220,9 +226,6 @@ export function assertNoBareHashPrefixLines(
|
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
|
|
223
|
-
/**
|
|
224
|
-
* Human-readable label for a resolved edit (used in warnings and conflict errors).
|
|
225
|
-
*/
|
|
226
229
|
export function describeEdit(edit: ResolvedHashlineEdit): string {
|
|
227
230
|
return `replace ${edit.old_range[0].hash}-${edit.old_range[1].hash}`;
|
|
228
231
|
}
|
|
@@ -233,7 +236,7 @@ export function validateAnchorEdits(
|
|
|
233
236
|
fileHashes: string[],
|
|
234
237
|
warnings: string[],
|
|
235
238
|
signal: AbortSignal | undefined,
|
|
236
|
-
): { resolved: ResolvedHashlineEdit[]; mismatches: HashMismatch[] } {
|
|
239
|
+
): { resolved: ResolvedHashlineEdit[]; mismatches: HashMismatch[]; boundaryWarnings: BoundaryDuplicationWarning[] } {
|
|
237
240
|
if (fileHashes.length !== fileLines.length) {
|
|
238
241
|
throw new Error(
|
|
239
242
|
`validateAnchorEdits: fileHashes.length (${fileHashes.length}) must match fileLines.length (${fileLines.length}).`,
|
|
@@ -241,6 +244,7 @@ export function validateAnchorEdits(
|
|
|
241
244
|
}
|
|
242
245
|
const resolved: ResolvedHashlineEdit[] = [];
|
|
243
246
|
const mismatches: HashMismatch[] = [];
|
|
247
|
+
const boundaryWarnings: BoundaryDuplicationWarning[] = [];
|
|
244
248
|
|
|
245
249
|
const tryResolve = (ref: Anchor): ResolvedAnchor | undefined => {
|
|
246
250
|
const result = resolveAnchor(ref, fileLines, fileHashes);
|
|
@@ -266,36 +270,38 @@ export function validateAnchorEdits(
|
|
|
266
270
|
}
|
|
267
271
|
const endLine = endResolved.line;
|
|
268
272
|
const nextLine = fileLines[endLine];
|
|
269
|
-
const replacementLastLine = edit.new_lines.at(-1)
|
|
273
|
+
const replacementLastLine = edit.new_lines.at(-1);
|
|
270
274
|
if (
|
|
271
275
|
nextLine !== undefined &&
|
|
272
|
-
replacementLastLine &&
|
|
273
|
-
|
|
274
|
-
replacementLastLine === nextLine
|
|
276
|
+
replacementLastLine !== undefined &&
|
|
277
|
+
replacementLastLine.length > 0 &&
|
|
278
|
+
replacementLastLine === nextLine
|
|
275
279
|
) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
280
|
+
boundaryWarnings.push({
|
|
281
|
+
kind: "trailing",
|
|
282
|
+
survivingLineContent: nextLine,
|
|
283
|
+
survivingLineIndex: endLine,
|
|
284
|
+
occurrence: fileLines.slice(0, endLine).filter(l => l === nextLine).length,
|
|
285
|
+
replacementLineContent: replacementLastLine,
|
|
286
|
+
editIndex: resolved.length,
|
|
287
|
+
});
|
|
283
288
|
}
|
|
284
289
|
const prevLine = fileLines[startResolved.line - 2];
|
|
285
|
-
const replacementFirstLine = edit.new_lines[0]
|
|
290
|
+
const replacementFirstLine = edit.new_lines[0];
|
|
286
291
|
if (
|
|
287
292
|
prevLine !== undefined &&
|
|
288
|
-
replacementFirstLine &&
|
|
289
|
-
|
|
290
|
-
replacementFirstLine === prevLine
|
|
293
|
+
replacementFirstLine !== undefined &&
|
|
294
|
+
replacementFirstLine.length > 0 &&
|
|
295
|
+
replacementFirstLine === prevLine
|
|
291
296
|
) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
297
|
+
boundaryWarnings.push({
|
|
298
|
+
kind: "leading",
|
|
299
|
+
survivingLineContent: prevLine,
|
|
300
|
+
survivingLineIndex: startResolved.line - 2,
|
|
301
|
+
occurrence: fileLines.slice(0, startResolved.line - 2).filter(l => l === prevLine).length,
|
|
302
|
+
replacementLineContent: replacementFirstLine,
|
|
303
|
+
editIndex: resolved.length,
|
|
304
|
+
});
|
|
299
305
|
}
|
|
300
306
|
resolved.push({
|
|
301
307
|
old_range: [startResolved, endResolved],
|
|
@@ -303,7 +309,7 @@ export function validateAnchorEdits(
|
|
|
303
309
|
});
|
|
304
310
|
}
|
|
305
311
|
|
|
306
|
-
return { resolved, mismatches };
|
|
312
|
+
return { resolved, mismatches, boundaryWarnings };
|
|
307
313
|
}
|
|
308
314
|
|
|
309
315
|
export { maybeWarnSuspiciousUnicodeEscapePlaceholder };
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
export function loadPrompt(relativePath: string, replacements?: Record<string, string>): string {
|
|
4
|
+
let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
|
|
5
|
+
if (replacements) {
|
|
6
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
7
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return content;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function loadPromptGuidelines(relativePath: string): string[] {
|
|
14
|
+
return readFileSync(new URL(relativePath, import.meta.url), "utf-8")
|
|
15
|
+
.split("\n")
|
|
16
|
+
.map((line) => line.trim())
|
|
17
|
+
.filter((line) => line.startsWith("- "))
|
|
18
|
+
.map((line) => line.slice(2));
|
|
19
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -8,9 +8,6 @@ import {
|
|
|
8
8
|
type TruncationResult,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { Type } from "typebox";
|
|
11
|
-
import { readFileSync } from "fs";
|
|
12
|
-
import { access as fsAccess } from "fs/promises";
|
|
13
|
-
import { constants } from "fs";
|
|
14
11
|
import { normalizeToLF, stripBom } from "./replace-diff";
|
|
15
12
|
import { loadFileKindAndText } from "./file-kind";
|
|
16
13
|
import { computeLineHashes, formatHashlineRegion } from "./hashline";
|
|
@@ -18,28 +15,16 @@ import { resolveToCwd } from "./path-utils";
|
|
|
18
15
|
import { throwIfAborted } from "./runtime";
|
|
19
16
|
import { getFileSnapshot } from "./snapshot";
|
|
20
17
|
import { getVisibleLines } from "./utils";
|
|
18
|
+
import { loadPrompt, loadPromptGuidelines } from "./prompts";
|
|
19
|
+
import { validateFileAccess, validateFileKind, isTextFile } from "./validation";
|
|
21
20
|
|
|
22
|
-
const READ_DESC =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const READ_PROMPT_SNIPPET = readFileSync(
|
|
31
|
-
new URL("../prompts/read-snippet.md", import.meta.url),
|
|
32
|
-
"utf-8",
|
|
33
|
-
).trim();
|
|
34
|
-
|
|
35
|
-
const READ_PROMPT_GUIDELINES = readFileSync(
|
|
36
|
-
new URL("../prompts/read-guidelines.md", import.meta.url),
|
|
37
|
-
"utf-8",
|
|
38
|
-
)
|
|
39
|
-
.split("\n")
|
|
40
|
-
.map((line) => line.trim())
|
|
41
|
-
.filter((line) => line.startsWith("- "))
|
|
42
|
-
.map((line) => line.slice(2));
|
|
21
|
+
const READ_DESC = loadPrompt("../prompts/read.md", {
|
|
22
|
+
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
|
|
23
|
+
DEFAULT_MAX_BYTES: formatSize(DEFAULT_MAX_BYTES),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const READ_PROMPT_SNIPPET = loadPrompt("../prompts/read-snippet.md");
|
|
27
|
+
const READ_PROMPT_GUIDELINES = loadPromptGuidelines("../prompts/read-guidelines.md");
|
|
43
28
|
|
|
44
29
|
function normalizePositiveInteger(
|
|
45
30
|
value: number | undefined,
|
|
@@ -151,35 +136,11 @@ export function registerReadTool(pi: ExtensionAPI): void {
|
|
|
151
136
|
const absolutePath = resolveToCwd(rawPath, ctx.cwd);
|
|
152
137
|
|
|
153
138
|
throwIfAborted(signal);
|
|
154
|
-
|
|
155
|
-
await fsAccess(absolutePath, constants.R_OK);
|
|
156
|
-
} catch (error: unknown) {
|
|
157
|
-
const code =
|
|
158
|
-
error instanceof Error
|
|
159
|
-
? (error as NodeJS.ErrnoException).code
|
|
160
|
-
: undefined;
|
|
161
|
-
if (code === "ENOENT") {
|
|
162
|
-
throw new Error(`File not found: ${rawPath}`);
|
|
163
|
-
}
|
|
164
|
-
if (code === "EACCES" || code === "EPERM") {
|
|
165
|
-
throw new Error(`File is not readable: ${rawPath}`);
|
|
166
|
-
}
|
|
167
|
-
throw new Error(`Cannot access file: ${rawPath}`);
|
|
168
|
-
}
|
|
139
|
+
await validateFileAccess(absolutePath, rawPath);
|
|
169
140
|
|
|
170
141
|
throwIfAborted(signal);
|
|
171
142
|
const file = await loadFileKindAndText(absolutePath);
|
|
172
|
-
|
|
173
|
-
throw new Error(
|
|
174
|
-
`Path is a directory: ${rawPath}. Use ls to inspect directories.`,
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (file.kind === "binary") {
|
|
179
|
-
throw new Error(
|
|
180
|
-
`Path is a binary file: ${rawPath} (${file.description}). Hashline read only supports text files and supported images.`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
143
|
+
validateFileKind(file, rawPath);
|
|
183
144
|
|
|
184
145
|
if (file.kind === "image") {
|
|
185
146
|
const builtinRead = createReadTool(ctx.cwd);
|
|
@@ -195,9 +156,6 @@ export function registerReadTool(pi: ExtensionAPI): void {
|
|
|
195
156
|
|
|
196
157
|
throwIfAborted(signal);
|
|
197
158
|
const normalized = normalizeToLF(stripBom(file.text).text);
|
|
198
|
-
// Compute hashes once for the whole file so the per-line anchors the
|
|
199
|
-
// model sees here are byte-identical to what the replace tool will
|
|
200
|
-
// compute when it later validates against this file.
|
|
201
159
|
const fileHashes = computeLineHashes(normalized);
|
|
202
160
|
const preview = formatHashlineReadPreview(
|
|
203
161
|
normalized,
|
|
@@ -209,9 +167,6 @@ export function registerReadTool(pi: ExtensionAPI): void {
|
|
|
209
167
|
);
|
|
210
168
|
const snapshot = await getFileSnapshot(absolutePath);
|
|
211
169
|
|
|
212
|
-
// Invalid UTF-8 bytes are decoded as U+FFFD, matching Pi's built-in
|
|
213
|
-
// tools. Warn only when the decoder reported invalid bytes; a literal,
|
|
214
|
-
// valid U+FFFD in a UTF-8 file should not be treated as lossy decoding.
|
|
215
170
|
const previewText =
|
|
216
171
|
file.hadUtf8DecodeErrors === true
|
|
217
172
|
? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
|
|
@@ -221,14 +176,10 @@ export function registerReadTool(pi: ExtensionAPI): void {
|
|
|
221
176
|
content: [{ type: "text", text: previewText }],
|
|
222
177
|
details: {
|
|
223
178
|
truncation: preview.truncation,
|
|
224
|
-
// snapshotId remains in details for host UI (e.g. "file changed since
|
|
225
|
-
// last view"). It is NOT echoed in text — the LLM no longer needs it.
|
|
226
179
|
snapshotId: snapshot.snapshotId,
|
|
227
180
|
...(preview.nextOffset !== undefined
|
|
228
181
|
? { nextOffset: preview.nextOffset }
|
|
229
182
|
: {}),
|
|
230
|
-
// Phase 2 C — host-only observability. Truncated reads usually mean
|
|
231
|
-
// a follow-up read with `offset = next_offset` is coming.
|
|
232
183
|
metrics: {
|
|
233
184
|
truncated: !!preview.truncation,
|
|
234
185
|
...(preview.nextOffset !== undefined
|
|
@@ -239,4 +190,4 @@ export function registerReadTool(pi: ExtensionAPI): void {
|
|
|
239
190
|
};
|
|
240
191
|
},
|
|
241
192
|
});
|
|
242
|
-
}
|
|
193
|
+
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -85,19 +85,16 @@ export function generateDiffString(
|
|
|
85
85
|
let linesToShow = raw;
|
|
86
86
|
let skipStart = 0;
|
|
87
87
|
let skipEnd = 0;
|
|
88
|
-
let skipMiddle = 0;
|
|
88
|
+
let skipMiddle = 0;
|
|
89
89
|
|
|
90
90
|
if (!lastWasChange) {
|
|
91
|
-
// Before a change: show last contextLines only.
|
|
92
91
|
skipStart = Math.max(0, raw.length - contextLines);
|
|
93
92
|
linesToShow = raw.slice(skipStart);
|
|
94
93
|
} else if (nextPartIsChange && raw.length > contextLines * 2) {
|
|
95
|
-
// Between two changes: show first contextLines + last contextLines with ellipsis in between.
|
|
96
94
|
const tail = raw.slice(-contextLines);
|
|
97
95
|
linesToShow = [...raw.slice(0, contextLines), "__ELLIPSIS__", ...tail];
|
|
98
96
|
skipMiddle = raw.length - contextLines * 2;
|
|
99
97
|
} else if (linesToShow.length > contextLines) {
|
|
100
|
-
// After a change with no next change nearby: show first contextLines only.
|
|
101
98
|
skipEnd = linesToShow.length - contextLines;
|
|
102
99
|
linesToShow = linesToShow.slice(0, contextLines);
|
|
103
100
|
}
|
|
@@ -234,4 +231,4 @@ export function buildCompactHashlineDiffPreview(
|
|
|
234
231
|
addedLines,
|
|
235
232
|
removedLines,
|
|
236
233
|
};
|
|
237
|
-
}
|
|
234
|
+
}
|
package/src/replace-normalize.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import { isRecord, hasOwn } from "./utils";
|
|
3
2
|
|
|
4
3
|
function coerceEditsArray(edits: unknown): unknown {
|
|
@@ -21,7 +20,6 @@ export function normalizeReplaceRequest(input: unknown): unknown {
|
|
|
21
20
|
|
|
22
21
|
const record: Record<string, unknown> = { ...input };
|
|
23
22
|
|
|
24
|
-
// file_path → path alias.
|
|
25
23
|
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
26
24
|
record.path = record.file_path;
|
|
27
25
|
delete record.file_path;
|
|
@@ -29,11 +27,9 @@ export function normalizeReplaceRequest(input: unknown): unknown {
|
|
|
29
27
|
|
|
30
28
|
const hasEditsField = hasOwn(record, "edits");
|
|
31
29
|
|
|
32
|
-
// edits-as-JSON-string → array.
|
|
33
30
|
if (hasEditsField) {
|
|
34
31
|
record.edits = coerceEditsArray(record.edits);
|
|
35
32
|
}
|
|
36
33
|
|
|
37
34
|
return record;
|
|
38
|
-
}
|
|
39
|
-
|
|
35
|
+
}
|
package/src/replace-render.ts
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TUI rendering helpers for the replace tool.
|
|
3
|
-
*
|
|
4
|
-
* Extracted from `src/replace.ts` to separate presentation (color themes, diff
|
|
5
|
-
* formatting, Markdown rendering) from tool execution logic.
|
|
6
|
-
*/
|
|
7
1
|
|
|
8
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
9
3
|
import { normalizeReplaceRequest } from "./replace-normalize";
|
|
10
4
|
import type { ReplaceRequestParams, HashlineReplaceToolDetails } from "./replace";
|
|
11
5
|
import { isRecord } from "./utils";
|
|
12
6
|
|
|
13
|
-
// ─── Theme type aliases ─────────────────────────────────────────────────
|
|
14
|
-
|
|
15
7
|
export type FgTheme = Pick<Theme, "fg">;
|
|
16
8
|
export type CallTheme = Pick<Theme, "fg" | "bold">;
|
|
17
9
|
export type RenderedMarkdownTheme = Pick<
|
|
@@ -19,8 +11,6 @@ export type RenderedMarkdownTheme = Pick<
|
|
|
19
11
|
"fg" | "bold" | "italic" | "underline" | "strikethrough"
|
|
20
12
|
>;
|
|
21
13
|
|
|
22
|
-
// ─── Render state ───────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
14
|
export type ReplacePreview = { diff: string } | { error: string };
|
|
25
15
|
|
|
26
16
|
export type ReplaceRenderState = {
|
|
@@ -29,8 +19,6 @@ export type ReplaceRenderState = {
|
|
|
29
19
|
previewGeneration?: number;
|
|
30
20
|
};
|
|
31
21
|
|
|
32
|
-
// ─── Preview input extraction ───────────────────────────────────────────
|
|
33
|
-
|
|
34
22
|
|
|
35
23
|
export function getRenderablePreviewInput(
|
|
36
24
|
args: unknown,
|
|
@@ -53,8 +41,6 @@ export function getRenderablePreviewInput(
|
|
|
53
41
|
return request.edits !== undefined ? request : null;
|
|
54
42
|
}
|
|
55
43
|
|
|
56
|
-
// ─── Diff formatting ────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
44
|
export function colorDiffLines(lines: string[], theme: FgTheme): string[] {
|
|
59
45
|
return lines.map((line) => {
|
|
60
46
|
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
@@ -88,8 +74,6 @@ export function formatResultDiff(diff: string, theme: FgTheme): string {
|
|
|
88
74
|
return colorDiffLines(diff.split("\n"), theme).join("\n");
|
|
89
75
|
}
|
|
90
76
|
|
|
91
|
-
// ─── Edit call formatting ───────────────────────────────────────────────
|
|
92
|
-
|
|
93
77
|
export function formatEditCall(
|
|
94
78
|
args: ReplaceRequestParams | undefined,
|
|
95
79
|
state: ReplaceRenderState,
|
|
@@ -118,8 +102,6 @@ export function formatEditCall(
|
|
|
118
102
|
return text;
|
|
119
103
|
}
|
|
120
104
|
|
|
121
|
-
// ─── Result text extraction ─────────────────────────────────────────────
|
|
122
|
-
|
|
123
105
|
export function getRenderedEditTextContent(result: {
|
|
124
106
|
content?: Array<{ type: string; text?: string }>;
|
|
125
107
|
}): string | undefined {
|
|
@@ -136,8 +118,6 @@ export function extractRenderedWarnings(
|
|
|
136
118
|
return text?.match(/(?:^|\n)Warnings:\n[\s\S]*$/)?.[0]?.trimStart();
|
|
137
119
|
}
|
|
138
120
|
|
|
139
|
-
// ─── Result classification ──────────────────────────────────────────────
|
|
140
|
-
|
|
141
121
|
export function isAppliedChangedResult(
|
|
142
122
|
details: HashlineReplaceToolDetails | undefined,
|
|
143
123
|
): boolean {
|
|
@@ -165,7 +145,6 @@ export function buildAppliedChangedResultText(
|
|
|
165
145
|
|
|
166
146
|
return sections.length > 0 ? sections.join("\n\n") : undefined;
|
|
167
147
|
}
|
|
168
|
-
// ─── Markdown rendering ─────────────────────────────────────────────────
|
|
169
148
|
|
|
170
149
|
function trimEdgeEmptyLines(lines: string[]): string[] {
|
|
171
150
|
let start = 0;
|
|
@@ -272,4 +251,4 @@ export function createRenderedEditMarkdownTheme(theme: RenderedMarkdownTheme) {
|
|
|
272
251
|
return theme.fg("mdCodeBlock", line);
|
|
273
252
|
}),
|
|
274
253
|
};
|
|
275
|
-
}
|
|
254
|
+
}
|
package/src/replace-response.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import { generateDiffString } from "./replace-diff";
|
|
3
2
|
import {
|
|
4
3
|
computeAffectedLineRange,
|
|
@@ -6,6 +5,7 @@ import {
|
|
|
6
5
|
formatHashlineRegion,
|
|
7
6
|
} from "./hashline";
|
|
8
7
|
import { getVisibleLines } from "./utils";
|
|
8
|
+
import { CHANGED_ANCHOR_TEXT_BUDGET_BYTES } from "./constants";
|
|
9
9
|
|
|
10
10
|
type ToolResult = {
|
|
11
11
|
content: Array<{ type: "text"; text: string }>;
|
|
@@ -13,9 +13,6 @@ type ToolResult = {
|
|
|
13
13
|
details: any;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
|
|
17
|
-
|
|
18
|
-
|
|
19
16
|
export type ReplaceMetrics = {
|
|
20
17
|
edits_attempted: number;
|
|
21
18
|
edits_noop: number;
|
|
@@ -45,7 +42,6 @@ type NoopEditEntry = {
|
|
|
45
42
|
};
|
|
46
43
|
|
|
47
44
|
|
|
48
|
-
// ─── Builder inputs ─────────────────────────────────────────────────────
|
|
49
45
|
|
|
50
46
|
export interface NoopResponseInput {
|
|
51
47
|
path: string;
|
|
@@ -65,7 +61,6 @@ export interface SuccessResponseInput {
|
|
|
65
61
|
editMeta: ReplaceMeta;
|
|
66
62
|
}
|
|
67
63
|
|
|
68
|
-
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
69
64
|
|
|
70
65
|
|
|
71
66
|
function countDiffLines(diff: string, marker: "+" | "-"): number {
|
|
@@ -118,7 +113,6 @@ function warningsBlockOf(warnings: string[] | undefined): string {
|
|
|
118
113
|
return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
119
114
|
}
|
|
120
115
|
|
|
121
|
-
// ─── Builders ───────────────────────────────────────────────────────────
|
|
122
116
|
|
|
123
117
|
export function buildNoopResponse(input: NoopResponseInput): ToolResult {
|
|
124
118
|
const {
|
|
@@ -190,10 +184,10 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
|
190
184
|
CHANGED_ANCHOR_TEXT_BUDGET_BYTES
|
|
191
185
|
? block
|
|
192
186
|
: "Anchors omitted; use read for subsequent edits.";
|
|
193
|
-
|
|
187
|
+
})()
|
|
194
188
|
: resultLines.length === 0
|
|
195
189
|
? "File is empty. Use edit to insert content."
|
|
196
|
-
: "";
|
|
190
|
+
: "";
|
|
197
191
|
const text = [anchorsBlock, warningsBlock.trimStart()]
|
|
198
192
|
.filter((section) => section.length > 0)
|
|
199
193
|
.join("\n\n");
|
|
@@ -219,4 +213,4 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
|
219
213
|
metrics,
|
|
220
214
|
},
|
|
221
215
|
};
|
|
222
|
-
}
|
|
216
|
+
}
|
package/src/replace.ts
CHANGED
|
@@ -6,8 +6,6 @@ import type {
|
|
|
6
6
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
8
|
import { constants } from "fs";
|
|
9
|
-
import { readFileSync } from "fs";
|
|
10
|
-
import { access as fsAccess } from "fs/promises";
|
|
11
9
|
import {
|
|
12
10
|
detectLineEnding,
|
|
13
11
|
generateDiffString,
|
|
@@ -45,6 +43,8 @@ import {
|
|
|
45
43
|
type ReplacePreview,
|
|
46
44
|
type ReplaceRenderState,
|
|
47
45
|
} from "./replace-render";
|
|
46
|
+
import { loadPrompt, loadPromptGuidelines } from "./prompts";
|
|
47
|
+
import { validateFileAccess, validateFileKind, isTextFile } from "./validation";
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
const hashlineEditNewLinesSchema = Type.Array(Type.String(), {
|
|
@@ -87,40 +87,15 @@ export type ReplaceRequestParams = {
|
|
|
87
87
|
export type HashlineReplaceToolDetails = {
|
|
88
88
|
diff: string;
|
|
89
89
|
firstChangedLine?: number;
|
|
90
|
-
/**
|
|
91
|
-
* Post-edit snapshot fingerprint. Surfaced in details only — the LLM no
|
|
92
|
-
* longer receives or echoes it. Hosts may use this for UI hints (e.g.
|
|
93
|
-
* "file changed since last view"). See plan W2.
|
|
94
|
-
*/
|
|
95
90
|
snapshotId?: string;
|
|
96
91
|
classification?: "noop";
|
|
97
92
|
structureOutline?: string[];
|
|
98
|
-
/**
|
|
99
|
-
* Phase 2 C — opt-in observability surface for hosts. Never echoed in text.
|
|
100
|
-
* Hosts can use it for adoption/regression dashboards.
|
|
101
|
-
*/
|
|
102
93
|
metrics?: ReplaceMetrics;
|
|
103
94
|
};
|
|
104
95
|
|
|
105
|
-
const EDIT_DESC =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
).trim();
|
|
109
|
-
|
|
110
|
-
const EDIT_PROMPT_SNIPPET = readFileSync(
|
|
111
|
-
new URL("../prompts/replace-snippet.md", import.meta.url),
|
|
112
|
-
"utf-8",
|
|
113
|
-
).trim();
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const EDIT_PROMPT_GUIDELINES = readFileSync(
|
|
117
|
-
new URL("../prompts/replace-guidelines.md", import.meta.url),
|
|
118
|
-
"utf-8",
|
|
119
|
-
)
|
|
120
|
-
.split("\n")
|
|
121
|
-
.map((line) => line.trim())
|
|
122
|
-
.filter((line) => line.startsWith("- "))
|
|
123
|
-
.map((line) => line.slice(2));
|
|
96
|
+
const EDIT_DESC = loadPrompt("../prompts/replace.md");
|
|
97
|
+
const EDIT_PROMPT_SNIPPET = loadPrompt("../prompts/replace-snippet.md");
|
|
98
|
+
const EDIT_PROMPT_GUIDELINES = loadPromptGuidelines("../prompts/replace-guidelines.md");
|
|
124
99
|
const ROOT_KEYS = new Set(["path", "edits"]);
|
|
125
100
|
|
|
126
101
|
export function assertReplaceRequest(
|
|
@@ -190,38 +165,11 @@ async function executeEditPipeline(
|
|
|
190
165
|
}
|
|
191
166
|
|
|
192
167
|
throwIfAborted(signal);
|
|
193
|
-
|
|
194
|
-
await fsAccess(absolutePath, accessMode);
|
|
195
|
-
} catch (error: unknown) {
|
|
196
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
197
|
-
if (code === "ENOENT") {
|
|
198
|
-
throw new Error(`File not found: ${path}`);
|
|
199
|
-
}
|
|
200
|
-
if (code === "EACCES" || code === "EPERM") {
|
|
201
|
-
const accessLabel =
|
|
202
|
-
accessMode & constants.W_OK ? "not writable" : "not readable";
|
|
203
|
-
throw new Error(`File is ${accessLabel}: ${path}`);
|
|
204
|
-
}
|
|
205
|
-
throw new Error(`Cannot access file: ${path}`);
|
|
206
|
-
}
|
|
168
|
+
await validateFileAccess(absolutePath, path, accessMode);
|
|
207
169
|
|
|
208
170
|
throwIfAborted(signal);
|
|
209
171
|
const file = await loadFileKindAndText(absolutePath);
|
|
210
|
-
|
|
211
|
-
throw new Error(
|
|
212
|
-
`Path is a directory: ${path}. Use ls to inspect directories.`,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
if (file.kind === "image") {
|
|
216
|
-
throw new Error(
|
|
217
|
-
`Path is an image file: ${path}. Hashline edit only supports text files.`,
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
if (file.kind === "binary") {
|
|
221
|
-
throw new Error(
|
|
222
|
-
`Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`,
|
|
223
|
-
);
|
|
224
|
-
}
|
|
172
|
+
validateFileKind(file, path);
|
|
225
173
|
|
|
226
174
|
throwIfAborted(signal);
|
|
227
175
|
const { bom, text: rawContent } = stripBom(file.text);
|
|
@@ -493,4 +441,4 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
493
441
|
|
|
494
442
|
export function registerReplaceTool(pi: ExtensionAPI): void {
|
|
495
443
|
pi.registerTool(editToolDefinition);
|
|
496
|
-
}
|
|
444
|
+
}
|
package/src/snapshot.ts
CHANGED
|
@@ -11,13 +11,6 @@ function formatSnapshotId(canonicalPath: string, info: { mtimeMs: number; size:
|
|
|
11
11
|
return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* Stat the file and return its current snapshot fingerprint.
|
|
16
|
-
*
|
|
17
|
-
* The snapshot is exposed only via `details.snapshotId` for host UIs (e.g.
|
|
18
|
-
* "file changed since last view"). It is no longer used to reject edits or
|
|
19
|
-
* surfaced in tool text — the LLM does not need to track it.
|
|
20
|
-
*/
|
|
21
14
|
export async function getFileSnapshot(absolutePath: string): Promise<SnapshotInfo> {
|
|
22
15
|
const canonicalPath = await resolveMutationTargetPath(absolutePath);
|
|
23
16
|
const stats = await stat(canonicalPath);
|
package/src/utils.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared type guards and utility helpers.
|
|
3
|
-
*/
|
|
4
1
|
|
|
5
2
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
6
3
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -10,18 +7,12 @@ export function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
|
|
10
7
|
return Object.hasOwn(record, key);
|
|
11
8
|
}
|
|
12
9
|
|
|
13
|
-
/**
|
|
14
|
-
* Return the visible lines of a text (excluding the terminal-newline sentinel).
|
|
15
|
-
*/
|
|
16
10
|
export function getVisibleLines(text: string): string[] {
|
|
17
11
|
if (text.length === 0) return [];
|
|
18
12
|
const lines = text.split("\n");
|
|
19
13
|
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
20
14
|
}
|
|
21
15
|
|
|
22
|
-
/**
|
|
23
|
-
* Count the visible lines of a text (excluding the terminal-newline sentinel).
|
|
24
|
-
*/
|
|
25
16
|
export function countVisibleLines(text: string): number {
|
|
26
17
|
return getVisibleLines(text).length;
|
|
27
18
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { constants } from "fs";
|
|
2
|
+
import { access as fsAccess } from "fs/promises";
|
|
3
|
+
import type { LoadedFile } from "./file-kind";
|
|
4
|
+
|
|
5
|
+
export async function validateFileAccess(
|
|
6
|
+
absolutePath: string,
|
|
7
|
+
path: string,
|
|
8
|
+
accessMode: number = constants.R_OK,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
try {
|
|
11
|
+
await fsAccess(absolutePath, accessMode);
|
|
12
|
+
} catch (error: unknown) {
|
|
13
|
+
const code = getErrorCode(error);
|
|
14
|
+
if (code === "ENOENT") {
|
|
15
|
+
throw new Error(`File not found: ${path}`);
|
|
16
|
+
}
|
|
17
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
18
|
+
const accessLabel = accessMode & constants.W_OK ? "not writable" : "not readable";
|
|
19
|
+
throw new Error(`File is ${accessLabel}: ${path}`);
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Cannot access file: ${path}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validateFileKind(file: LoadedFile, path: string): void {
|
|
26
|
+
if (file.kind === "directory") {
|
|
27
|
+
throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
|
|
28
|
+
}
|
|
29
|
+
if (file.kind === "binary") {
|
|
30
|
+
throw new Error(`Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`);
|
|
31
|
+
}
|
|
32
|
+
if (file.kind === "image") {
|
|
33
|
+
throw new Error(`Path is an image file: ${path}. Hashline edit only supports text files.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isTextFile(file: LoadedFile): file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
|
|
38
|
+
return file.kind === "text";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getErrorCode(error: unknown): string | undefined {
|
|
42
|
+
if (error instanceof Error) {
|
|
43
|
+
return (error as NodeJS.ErrnoException).code;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|