@jerryan/pi-hashline-edit 0.7.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.
@@ -0,0 +1,13 @@
1
+ import * as os from "os";
2
+ import { isAbsolute, resolve as resolvePath } from "path";
3
+
4
+ function expandPath(filePath: string): string {
5
+ if (filePath === "~") return os.homedir();
6
+ if (filePath.startsWith("~/")) return os.homedir() + filePath.slice(1);
7
+ return filePath;
8
+ }
9
+
10
+ export function resolveToCwd(filePath: string, cwd: string): string {
11
+ const expanded = expandPath(filePath);
12
+ return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
13
+ }
package/src/read.ts ADDED
@@ -0,0 +1,222 @@
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 { 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; raw?: boolean },
70
+ ): { text: string; truncation?: TruncationResult; nextOffset?: number } {
71
+ const allLines = getPreviewLines(text);
72
+ const totalLines = allLines.length;
73
+ const startLine = normalizePositiveInteger(options.offset, "offset") ?? 1;
74
+ if (totalLines === 0) {
75
+ if (startLine === 1) {
76
+ return {
77
+ text: "File is empty. Use edit with prepend or append and omit pos to insert content.",
78
+ };
79
+ }
80
+
81
+ return {
82
+ 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.`,
83
+ };
84
+ }
85
+
86
+ if (startLine > totalLines) {
87
+ return {
88
+ 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.`,
89
+ };
90
+ }
91
+
92
+ const limit = normalizePositiveInteger(options.limit, "limit");
93
+ const endIdx = limit
94
+ ? Math.min(startLine - 1 + limit, totalLines)
95
+ : totalLines;
96
+ const selected = allLines.slice(startLine - 1, endIdx);
97
+ const formatted = options.raw ? selected.join("\n") : formatHashlineRegion(selected, startLine);
98
+
99
+ const truncation = truncateHead(formatted);
100
+ if (truncation.firstLineExceedsLimit) {
101
+ return {
102
+ text: `[Line ${startLine} exceeds ${formatSize(truncation.maxBytes)}.${options.raw ? "" : " Hashline output requires full lines; cannot compute hashes for a truncated preview."}]`,
103
+
104
+ truncation,
105
+ };
106
+ }
107
+
108
+ let preview = truncation.content;
109
+ let nextOffset: number | undefined;
110
+ if (truncation.truncated) {
111
+ const endLineDisplay = startLine + truncation.outputLines - 1;
112
+ nextOffset = endLineDisplay + 1;
113
+ if (truncation.truncatedBy === "lines") {
114
+ preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
115
+ } else {
116
+ preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
117
+ }
118
+ } else if (endIdx < totalLines) {
119
+ nextOffset = endIdx + 1;
120
+ preview += `\n\n[Showing lines ${startLine}-${endIdx} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
121
+ }
122
+
123
+ return {
124
+ text: preview,
125
+ truncation: truncation.truncated ? truncation : undefined,
126
+ ...(nextOffset !== undefined ? { nextOffset } : {}),
127
+ };
128
+ }
129
+
130
+ export function registerReadTool(pi: ExtensionAPI): void {
131
+ pi.registerTool({
132
+ name: "read",
133
+ label: "Read",
134
+ description: READ_DESC,
135
+ promptSnippet: READ_PROMPT_SNIPPET,
136
+ promptGuidelines: READ_PROMPT_GUIDELINES,
137
+ parameters: Type.Object({
138
+ path: Type.String({
139
+ description: "Path to the file to read (relative or absolute)",
140
+ }),
141
+ offset: Type.Optional(
142
+ Type.Integer({
143
+ minimum: 1,
144
+ description: "Line number to start reading from (1-indexed)",
145
+ }),
146
+ ),
147
+ limit: Type.Optional(
148
+ Type.Integer({
149
+ minimum: 1,
150
+ description: "Maximum number of lines to read",
151
+ }),
152
+ ),
153
+ raw: Type.Optional(
154
+ Type.Boolean({
155
+ description: "Return raw text without LINE#HASH anchors, saving tokens. Don't use if you plan to edit this file.",
156
+ }),
157
+ ),
158
+ }),
159
+
160
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
161
+ const rawPath = params.path;
162
+ const absolutePath = resolveToCwd(rawPath, ctx.cwd);
163
+
164
+ throwIfAborted(signal);
165
+ try {
166
+ await fsAccess(absolutePath, constants.R_OK);
167
+ } catch (error: unknown) {
168
+ const code = error instanceof Error
169
+ ? (error as NodeJS.ErrnoException).code
170
+ : undefined;
171
+ if (code === "ENOENT") {
172
+ throw new Error(`File not found: ${rawPath}`);
173
+ }
174
+ if (code === "EACCES" || code === "EPERM") {
175
+ throw new Error(`File is not readable: ${rawPath}`);
176
+ }
177
+ throw new Error(`Cannot access file: ${rawPath}`);
178
+ }
179
+
180
+ throwIfAborted(signal);
181
+ const file = await loadFileKindAndText(absolutePath);
182
+ if (file.kind === "directory") {
183
+ throw new Error(`Path is a directory: ${rawPath}. Use ls to inspect directories.`);
184
+ }
185
+
186
+ if (file.kind === "binary") {
187
+ throw new Error(`Path is a binary file: ${rawPath} (${file.description}). Read only supports UTF-8 text files and supported images.`);
188
+ }
189
+
190
+ if (file.kind === "image") {
191
+ const builtinRead = createReadTool(ctx.cwd);
192
+ return builtinRead.execute(_toolCallId, params, signal, _onUpdate, ctx);
193
+ }
194
+
195
+ throwIfAborted(signal);
196
+ const normalized = normalizeToLF(stripBom(file.text).text);
197
+ const preview = formatHashlineReadPreview(normalized, {
198
+ offset: params.offset,
199
+ limit: params.limit,
200
+ raw: params.raw,
201
+ });
202
+ const snapshot = await getFileSnapshot(absolutePath);
203
+
204
+ return {
205
+ content: [{ type: "text", text: preview.text }],
206
+ details: {
207
+ truncation: preview.truncation,
208
+ // snapshotId remains in details for host UI (e.g. "file changed since
209
+ // last view"). It is NOT echoed in text — the LLM no longer needs it.
210
+ snapshotId: snapshot.snapshotId,
211
+ ...(preview.nextOffset !== undefined ? { nextOffset: preview.nextOffset } : {}),
212
+ // Phase 2 C — host-only observability. Truncated reads usually mean
213
+ // a follow-up read with `offset = next_offset` is coming.
214
+ metrics: {
215
+ truncated: !!preview.truncation,
216
+ ...(preview.nextOffset !== undefined ? { next_offset: preview.nextOffset } : {}),
217
+ },
218
+ },
219
+ };
220
+ },
221
+ });
222
+ }
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
+ }