pi-hashline-edit-pro 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/read.ts ADDED
@@ -0,0 +1,256 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ createReadTool,
4
+ formatSize,
5
+ DEFAULT_MAX_BYTES,
6
+ DEFAULT_MAX_LINES,
7
+ truncateHead,
8
+ type TruncationResult,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "@sinclair/typebox";
11
+ import { readFileSync } from "fs";
12
+ import { access as fsAccess } from "fs/promises";
13
+ import { constants } from "fs";
14
+ import { normalizeToLF, stripBom } from "./edit-diff";
15
+ import { loadFileKindAndText } from "./file-kind";
16
+ import { computeLineHashes, formatHashlineRegion } from "./hashline";
17
+ import { resolveToCwd } from "./path-utils";
18
+ import { throwIfAborted } from "./runtime";
19
+ import { getFileSnapshot } from "./snapshot";
20
+
21
+ const READ_DESC = readFileSync(
22
+ new URL("../prompts/read.md", import.meta.url),
23
+ "utf-8",
24
+ )
25
+ .replaceAll("{{DEFAULT_MAX_LINES}}", String(DEFAULT_MAX_LINES))
26
+ .replaceAll("{{DEFAULT_MAX_BYTES}}", formatSize(DEFAULT_MAX_BYTES))
27
+ .trim();
28
+
29
+ const READ_PROMPT_SNIPPET = readFileSync(
30
+ new URL("../prompts/read-snippet.md", import.meta.url),
31
+ "utf-8",
32
+ ).trim();
33
+
34
+ const READ_PROMPT_GUIDELINES = readFileSync(
35
+ new URL("../prompts/read-guidelines.md", import.meta.url),
36
+ "utf-8",
37
+ )
38
+ .split("\n")
39
+ .map((line) => line.trim())
40
+ .filter((line) => line.startsWith("- "))
41
+ .map((line) => line.slice(2));
42
+
43
+ function normalizePositiveInteger(
44
+ value: number | undefined,
45
+ name: "offset" | "limit",
46
+ ): number | undefined {
47
+ if (value === undefined) {
48
+ return undefined;
49
+ }
50
+
51
+ if (!Number.isInteger(value) || value < 1) {
52
+ throw new Error(`Read request field "${name}" must be a positive integer.`);
53
+ }
54
+
55
+ return value;
56
+ }
57
+
58
+ function getPreviewLines(text: string): string[] {
59
+ if (text.length === 0) {
60
+ return [];
61
+ }
62
+
63
+ const lines = text.split("\n");
64
+ return text.endsWith("\n") ? lines.slice(0, -1) : lines;
65
+ }
66
+
67
+ export function formatHashlineReadPreview(
68
+ text: string,
69
+ options: { offset?: number; limit?: number },
70
+ precomputedHashes?: string[],
71
+ ): { text: string; truncation?: TruncationResult; nextOffset?: number } {
72
+ const allLines = getPreviewLines(text);
73
+ const totalLines = allLines.length;
74
+ const startLine = normalizePositiveInteger(options.offset, "offset") ?? 1;
75
+ if (totalLines === 0) {
76
+ if (startLine === 1) {
77
+ return {
78
+ text: "File is empty. Use edit with prepend or append and omit pos to insert content.",
79
+ };
80
+ }
81
+
82
+ return {
83
+ text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use edit with prepend or append and omit pos to insert content.`,
84
+ };
85
+ }
86
+
87
+ if (startLine > totalLines) {
88
+ return {
89
+ text: `Offset ${startLine} is beyond end of file (${totalLines} lines total). Use offset=1 to read from the start, or offset=${totalLines} to read the last line.`,
90
+ };
91
+ }
92
+
93
+ const limit = normalizePositiveInteger(options.limit, "limit");
94
+ const endIdx = limit
95
+ ? Math.min(startLine - 1 + limit, totalLines)
96
+ : totalLines;
97
+ const selected = allLines.slice(startLine - 1, endIdx);
98
+ // The runtime precomputes the full per-line hash array so the model sees
99
+ // hashes consistent with what validation will compare against. Callers that
100
+ // already have one (the read tool, chained-edit tests) can pass it in;
101
+ // otherwise we recompute here for the visible window — acceptable for
102
+ // preview formatting because the hashes themselves are identical regardless
103
+ // of which slice of the file we look at.
104
+ const allHashes = precomputedHashes ?? computeLineHashes(text);
105
+ const selectedHashes = allHashes.slice(startLine - 1, endIdx);
106
+ const formatted = formatHashlineRegion(selectedHashes, selected);
107
+
108
+ const truncation = truncateHead(formatted);
109
+ if (truncation.firstLineExceedsLimit) {
110
+ return {
111
+ text: `[Line ${startLine} exceeds ${formatSize(truncation.maxBytes)}. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`,
112
+ truncation,
113
+ };
114
+ }
115
+
116
+ let preview = truncation.content;
117
+ let nextOffset: number | undefined;
118
+ if (truncation.truncated) {
119
+ const endLineDisplay = startLine + truncation.outputLines - 1;
120
+ nextOffset = endLineDisplay + 1;
121
+ if (truncation.truncatedBy === "lines") {
122
+ preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
123
+ } else {
124
+ preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
125
+ }
126
+ } else if (endIdx < totalLines) {
127
+ nextOffset = endIdx + 1;
128
+ preview += `\n\n[Showing lines ${startLine}-${endIdx} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
129
+ }
130
+
131
+ return {
132
+ text: preview,
133
+ truncation: truncation.truncated ? truncation : undefined,
134
+ ...(nextOffset !== undefined ? { nextOffset } : {}),
135
+ };
136
+ }
137
+
138
+ export function registerReadTool(pi: ExtensionAPI): void {
139
+ pi.registerTool({
140
+ name: "read",
141
+ label: "Read",
142
+ description: READ_DESC,
143
+ promptSnippet: READ_PROMPT_SNIPPET,
144
+ promptGuidelines: READ_PROMPT_GUIDELINES,
145
+ parameters: Type.Object({
146
+ path: Type.String({
147
+ description: "Path to the file to read (relative or absolute)",
148
+ }),
149
+ offset: Type.Optional(
150
+ Type.Integer({
151
+ minimum: 1,
152
+ description: "Line number to start reading from (1-indexed)",
153
+ }),
154
+ ),
155
+ limit: Type.Optional(
156
+ Type.Integer({
157
+ minimum: 1,
158
+ description: "Maximum number of lines to read",
159
+ }),
160
+ ),
161
+ }),
162
+
163
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
164
+ const rawPath = params.path;
165
+ const absolutePath = resolveToCwd(rawPath, ctx.cwd);
166
+
167
+ throwIfAborted(signal);
168
+ try {
169
+ await fsAccess(absolutePath, constants.R_OK);
170
+ } catch (error: unknown) {
171
+ const code =
172
+ error instanceof Error
173
+ ? (error as NodeJS.ErrnoException).code
174
+ : undefined;
175
+ if (code === "ENOENT") {
176
+ throw new Error(`File not found: ${rawPath}`);
177
+ }
178
+ if (code === "EACCES" || code === "EPERM") {
179
+ throw new Error(`File is not readable: ${rawPath}`);
180
+ }
181
+ throw new Error(`Cannot access file: ${rawPath}`);
182
+ }
183
+
184
+ throwIfAborted(signal);
185
+ const file = await loadFileKindAndText(absolutePath);
186
+ if (file.kind === "directory") {
187
+ throw new Error(
188
+ `Path is a directory: ${rawPath}. Use ls to inspect directories.`,
189
+ );
190
+ }
191
+
192
+ if (file.kind === "binary") {
193
+ throw new Error(
194
+ `Path is a binary file: ${rawPath} (${file.description}). Hashline read only supports text files and supported images.`,
195
+ );
196
+ }
197
+
198
+ if (file.kind === "image") {
199
+ const builtinRead = createReadTool(ctx.cwd);
200
+ const executeBuiltinRead = builtinRead.execute as unknown as (
201
+ toolCallId: string,
202
+ input: typeof params,
203
+ abortSignal: typeof signal,
204
+ onUpdate: typeof _onUpdate,
205
+ context: typeof ctx,
206
+ ) => ReturnType<typeof builtinRead.execute>;
207
+ return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
208
+ }
209
+
210
+ throwIfAborted(signal);
211
+ const normalized = normalizeToLF(stripBom(file.text).text);
212
+ // Compute hashes once for the whole file so the per-line anchors the
213
+ // model sees here are byte-identical to what the edit tool will
214
+ // compute when it later validates against this file.
215
+ const fileHashes = computeLineHashes(normalized);
216
+ const preview = formatHashlineReadPreview(
217
+ normalized,
218
+ {
219
+ offset: params.offset,
220
+ limit: params.limit,
221
+ },
222
+ fileHashes,
223
+ );
224
+ const snapshot = await getFileSnapshot(absolutePath);
225
+
226
+ // Invalid UTF-8 bytes are decoded as U+FFFD, matching Pi's built-in
227
+ // tools. Warn only when the decoder reported invalid bytes; a literal,
228
+ // valid U+FFFD in a UTF-8 file should not be treated as lossy decoding.
229
+ const previewText =
230
+ file.hadUtf8DecodeErrors === true
231
+ ? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
232
+ : preview.text;
233
+
234
+ return {
235
+ content: [{ type: "text", text: previewText }],
236
+ details: {
237
+ truncation: preview.truncation,
238
+ // snapshotId remains in details for host UI (e.g. "file changed since
239
+ // last view"). It is NOT echoed in text — the LLM no longer needs it.
240
+ snapshotId: snapshot.snapshotId,
241
+ ...(preview.nextOffset !== undefined
242
+ ? { nextOffset: preview.nextOffset }
243
+ : {}),
244
+ // Phase 2 C — host-only observability. Truncated reads usually mean
245
+ // a follow-up read with `offset = next_offset` is coming.
246
+ metrics: {
247
+ truncated: !!preview.truncation,
248
+ ...(preview.nextOffset !== undefined
249
+ ? { next_offset: preview.nextOffset }
250
+ : {}),
251
+ },
252
+ },
253
+ };
254
+ },
255
+ });
256
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function throwIfAborted(signal?: AbortSignal): void {
2
+ if (signal?.aborted) throw new Error("Operation aborted");
3
+ }
@@ -0,0 +1,29 @@
1
+ import { stat } from "fs/promises";
2
+ import { resolveMutationTargetPath } from "./fs-write";
3
+
4
+ export type SnapshotInfo = {
5
+ snapshotId: string;
6
+ mtimeMs: number;
7
+ size: number;
8
+ };
9
+
10
+ function formatSnapshotId(canonicalPath: string, info: { mtimeMs: number; size: number }): string {
11
+ return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
12
+ }
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
+ export async function getFileSnapshot(absolutePath: string): Promise<SnapshotInfo> {
22
+ const canonicalPath = await resolveMutationTargetPath(absolutePath);
23
+ const stats = await stat(canonicalPath);
24
+ return {
25
+ snapshotId: formatSnapshotId(canonicalPath, stats),
26
+ mtimeMs: stats.mtimeMs,
27
+ size: stats.size,
28
+ };
29
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared type guards and utility helpers.
3
+ */
4
+
5
+ export function isRecord(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ export function hasOwn(record: Record<string, unknown>, key: string): boolean {
10
+ return Object.hasOwn(record, key);
11
+ }