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/LICENSE +21 -0
- package/README.md +143 -0
- package/index.ts +64 -0
- package/package.json +52 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +58 -0
- package/prompts/read-guidelines.md +3 -0
- package/prompts/read-snippet.md +1 -0
- package/prompts/read.md +28 -0
- package/src/edit-diff.ts +234 -0
- package/src/edit-normalize.ts +68 -0
- package/src/edit-render.ts +280 -0
- package/src/edit-response.ts +531 -0
- package/src/edit.ts +689 -0
- package/src/file-kind.ts +161 -0
- package/src/fs-write.ts +105 -0
- package/src/hashline/apply.ts +660 -0
- package/src/hashline/hash.ts +192 -0
- package/src/hashline/index.ts +70 -0
- package/src/hashline/parse.ts +116 -0
- package/src/hashline/resolve.ts +552 -0
- package/src/path-utils.ts +13 -0
- package/src/read.ts +256 -0
- package/src/runtime.ts +3 -0
- package/src/snapshot.ts +29 -0
- package/src/utils.ts +11 -0
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
package/src/snapshot.ts
ADDED
|
@@ -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
|
+
}
|