pi-hashline-edit-pro 0.9.4 → 0.9.6
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/LICENSE +1 -1
- package/index.ts +5 -14
- package/package.json +1 -1
- package/prompts/replace-guidelines.md +2 -2
- package/src/constants.ts +5 -0
- package/src/file-kind.ts +2 -9
- package/src/hashline/apply.ts +3 -13
- package/src/hashline/hash.ts +1 -11
- package/src/hashline/resolve.ts +2 -8
- 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 +10 -62
- package/src/snapshot.ts +0 -7
- package/src/utils.ts +0 -9
- package/src/validation.ts +46 -0
package/LICENSE
CHANGED
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
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
|
|
2
|
-
- After a successful `replace`, the
|
|
3
|
-
- On `[E_STALE_ANCHOR]`,
|
|
2
|
+
- After a successful `replace`, the response text is empty (warnings only). Call `read` to get fresh anchors for follow-up edits.
|
|
3
|
+
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH from each line into `old_range`, and retry.
|
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 {
|
|
@@ -13,7 +12,7 @@ import {
|
|
|
13
12
|
type BoundaryDuplicationWarning,
|
|
14
13
|
} from "./resolve";
|
|
15
14
|
import { countVisibleLines } from "../utils";
|
|
16
|
-
|
|
15
|
+
import { ANCHOR_CONTEXT_LINES, ANCHOR_MAX_OUTPUT_LINES } from "../constants";
|
|
17
16
|
|
|
18
17
|
type LineIndex = {
|
|
19
18
|
fileLines: string[];
|
|
@@ -236,7 +235,6 @@ export function applyHashlineEdits(
|
|
|
236
235
|
lastChangedLine: undefined,
|
|
237
236
|
};
|
|
238
237
|
|
|
239
|
-
// Normalize new_lines: [""] to new_lines: [] for deletion.
|
|
240
238
|
edits = edits.map((edit) =>
|
|
241
239
|
edit.new_lines.length === 1 &&
|
|
242
240
|
edit.new_lines[0] === ""
|
|
@@ -278,9 +276,6 @@ export function applyHashlineEdits(
|
|
|
278
276
|
assertDoesNotEmptyFile(content, result);
|
|
279
277
|
const changedRange = computeChangedLineRange(content, result);
|
|
280
278
|
|
|
281
|
-
// Reconstruct boundary duplication warnings with post-edit hashes.
|
|
282
|
-
// Use position + occurrence to find the exact surviving line (handles
|
|
283
|
-
// files with multiple identical lines like several `}` closings).
|
|
284
279
|
const resultLines = result.split("\n");
|
|
285
280
|
const resultHashes = computeLineHashes(result);
|
|
286
281
|
for (const bw of boundaryWarnings) {
|
|
@@ -316,9 +311,6 @@ export function applyHashlineEdits(
|
|
|
316
311
|
}
|
|
317
312
|
|
|
318
313
|
|
|
319
|
-
const ANCHOR_CONTEXT_LINES = 0;
|
|
320
|
-
const ANCHOR_MAX_OUTPUT_LINES = 12;
|
|
321
|
-
|
|
322
314
|
export function computeAffectedLineRange(params: {
|
|
323
315
|
firstChangedLine: number | undefined;
|
|
324
316
|
lastChangedLine: number | undefined;
|
|
@@ -338,8 +330,6 @@ export function computeAffectedLineRange(params: {
|
|
|
338
330
|
return null;
|
|
339
331
|
}
|
|
340
332
|
|
|
341
|
-
// When contextLines is 0, skip the anchor block entirely.
|
|
342
|
-
// The LLM already knows what it changed and can call read for fresh anchors.
|
|
343
333
|
if (contextLines === 0) {
|
|
344
334
|
return null;
|
|
345
335
|
}
|
|
@@ -405,6 +395,7 @@ export function computeChangedLineRange(
|
|
|
405
395
|
}
|
|
406
396
|
if (firstDiff === minLen && original.length === result.length) return null;
|
|
407
397
|
|
|
398
|
+
|
|
408
399
|
let lastOrig = original.length - 1;
|
|
409
400
|
let lastRes = result.length - 1;
|
|
410
401
|
while (
|
|
@@ -439,5 +430,4 @@ export function computeChangedLineRange(
|
|
|
439
430
|
}
|
|
440
431
|
|
|
441
432
|
return { firstChangedLine, lastChangedLine };
|
|
442
|
-
}
|
|
443
|
-
|
|
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/resolve.ts
CHANGED
|
@@ -39,9 +39,7 @@ export interface NoopEdit {
|
|
|
39
39
|
export type HashlineToolEdit = {
|
|
40
40
|
old_range?: [string, string];
|
|
41
41
|
new_lines?: string[];
|
|
42
|
-
/** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
|
|
43
42
|
oldText?: string;
|
|
44
|
-
/** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
|
|
45
43
|
newText?: string;
|
|
46
44
|
};
|
|
47
45
|
|
|
@@ -136,7 +134,7 @@ function assertEditItem(edit: Record<string, unknown>, index: number): void {
|
|
|
136
134
|
const unknownKeys = Object.keys(edit).filter((key) => !ITEM_KEYS.has(key));
|
|
137
135
|
if (unknownKeys.length > 0) {
|
|
138
136
|
throw new Error(
|
|
139
|
-
`[E_BAD_SHAPE] Edit ${index} contains unknown or unsupported fields: ${unknownKeys.join(", ")}.`,
|
|
137
|
+
`[E_BAD_SHAPE] Edit ${index} contains unknown or unsupported fields: ${unknownKeys.join(", ")}. Each edit takes only { old_range, new_lines }.`,
|
|
140
138
|
);
|
|
141
139
|
}
|
|
142
140
|
|
|
@@ -146,7 +144,7 @@ function assertEditItem(edit: Record<string, unknown>, index: number): void {
|
|
|
146
144
|
);
|
|
147
145
|
}
|
|
148
146
|
if (!("new_lines" in edit)) {
|
|
149
|
-
throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "new_lines" field.`);
|
|
147
|
+
throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "new_lines" field. Provide the replacement lines (use [] to delete).`);
|
|
150
148
|
}
|
|
151
149
|
if ("new_lines" in edit && !isStringArray(edit.new_lines)) {
|
|
152
150
|
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "new_lines" must be a string array.`);
|
|
@@ -164,7 +162,6 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
|
164
162
|
for (const [index, edit] of edits.entries()) {
|
|
165
163
|
assertEditItem(edit as Record<string, unknown>, index);
|
|
166
164
|
|
|
167
|
-
// Normalize new_lines: [""] to new_lines: [] for deletion.
|
|
168
165
|
const replaceLines = hashlineParseText(edit.new_lines ?? null);
|
|
169
166
|
const normalizedLines =
|
|
170
167
|
replaceLines.length === 1 && replaceLines[0] === ""
|
|
@@ -229,9 +226,6 @@ export function assertNoBareHashPrefixLines(
|
|
|
229
226
|
}
|
|
230
227
|
|
|
231
228
|
|
|
232
|
-
/**
|
|
233
|
-
* Human-readable label for a resolved edit (used in warnings and conflict errors).
|
|
234
|
-
*/
|
|
235
229
|
export function describeEdit(edit: ResolvedHashlineEdit): string {
|
|
236
230
|
return `replace ${edit.old_range[0].hash}-${edit.old_range[1].hash}`;
|
|
237
231
|
}
|
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(
|
|
@@ -143,7 +118,7 @@ export function assertReplaceRequest(
|
|
|
143
118
|
);
|
|
144
119
|
if (unknownRootKeys.length > 0) {
|
|
145
120
|
throw new Error(
|
|
146
|
-
`[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: ${unknownRootKeys.join(", ")}.`,
|
|
121
|
+
`[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: ${unknownRootKeys.join(", ")}. Use only { path, edits }; each edit is { old_range: ["<START>", "<END>"], new_lines: [...] }.`,
|
|
147
122
|
);
|
|
148
123
|
}
|
|
149
124
|
|
|
@@ -152,7 +127,7 @@ export function assertReplaceRequest(
|
|
|
152
127
|
}
|
|
153
128
|
|
|
154
129
|
if (hasOwn(request, "edits") && !Array.isArray(request.edits)) {
|
|
155
|
-
throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array when provided.');
|
|
130
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array when provided. Each edit is { old_range: ["<START>", "<END>"], new_lines: [...] }.');
|
|
156
131
|
}
|
|
157
132
|
|
|
158
133
|
}
|
|
@@ -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
|
+
}
|