pi-hashline-edit-pro 2.7.2 → 2.8.1
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 +7 -1
- package/index.ts +9 -6
- package/package.json +10 -10
- package/prompts/grep-guidelines.md +3 -7
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +3 -7
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -9
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -4
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/commit.ts +2 -1
- package/src/edit-common.ts +45 -0
- package/src/file-kind.ts +14 -8
- package/src/file-reader.ts +34 -13
- package/src/fs-write.ts +60 -3
- package/src/grep.ts +119 -32
- package/src/hash-store/cache.ts +18 -0
- package/src/hash-store/retry.ts +48 -0
- package/src/hash-store/validation.ts +62 -0
- package/src/hash-store.ts +68 -133
- package/src/hashline/hash.ts +11 -9
- package/src/hashline/parse.ts +15 -1
- package/src/hashline/resolve.ts +3 -2
- package/src/insert.ts +11 -32
- package/src/normalize.ts +27 -0
- package/src/payload-contract.ts +102 -0
- package/src/read.ts +15 -1
- package/src/replace-diff.ts +28 -45
- package/src/replace-render.ts +18 -40
- package/src/replace-response.ts +1 -0
- package/src/replace-undo.ts +28 -13
- package/src/replace.ts +49 -128
- package/src/served.ts +26 -5
- package/src/utils.ts +58 -0
- package/src/validation.ts +2 -2
- package/src/write-hook.ts +59 -0
- package/src/replace-normalize.ts +0 -13
package/src/replace-undo.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from "fs";
|
|
2
|
+
import { open } from "fs/promises";
|
|
2
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { Type } from "typebox";
|
|
5
|
-
import { loadHashStore,
|
|
6
|
+
import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
6
7
|
import { recordServedDiff } from "./served";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { cntDiff, splitLines, errCode, makePrepareArguments } from "./utils";
|
|
8
|
+
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
9
|
+
import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
|
|
10
|
+
import { genDiff, genPatch } from "./replace-diff";
|
|
11
|
+
import { cntDiff, errCode, makePrepareArguments } from "./utils";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { buildMetrics } from "./replace-response";
|
|
14
|
+
import { renderEditResult } from "./replace-render";
|
|
14
15
|
import { changedRange, lineHashes } from "./hashline";
|
|
15
16
|
export interface UndoEntry {
|
|
16
17
|
content: string;
|
|
@@ -98,11 +99,13 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
98
99
|
description: "Path to the file to undo",
|
|
99
100
|
}),
|
|
100
101
|
}),
|
|
101
|
-
|
|
102
|
+
executionMode: "sequential",
|
|
103
|
+
renderResult(result, opts, theme, context) {
|
|
104
|
+
return renderEditResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
|
|
105
|
+
},
|
|
102
106
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
103
107
|
const path = params.path;
|
|
104
|
-
const
|
|
105
|
-
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
108
|
+
const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
|
|
106
109
|
|
|
107
110
|
const undo = await getUndo(mutationTargetPath);
|
|
108
111
|
if (!undo) {
|
|
@@ -120,8 +123,17 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
120
123
|
|
|
121
124
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
122
125
|
let currentRaw: string | undefined;
|
|
126
|
+
let currentIdentity: FileIdentity | undefined;
|
|
123
127
|
try {
|
|
124
|
-
|
|
128
|
+
const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
|
|
129
|
+
const handle = await open(mutationTargetPath, constants.O_RDONLY | noFollow);
|
|
130
|
+
try {
|
|
131
|
+
const { dev, ino } = await handle.stat();
|
|
132
|
+
currentIdentity = { dev, ino };
|
|
133
|
+
currentRaw = await handle.readFile("utf-8");
|
|
134
|
+
} finally {
|
|
135
|
+
await handle.close();
|
|
136
|
+
}
|
|
125
137
|
} catch (error) {
|
|
126
138
|
if (errCode(error) !== "ENOENT") throw error;
|
|
127
139
|
}
|
|
@@ -145,6 +157,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
145
157
|
await writeAtomic(
|
|
146
158
|
mutationTargetPath,
|
|
147
159
|
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
160
|
+
currentIdentity,
|
|
148
161
|
);
|
|
149
162
|
|
|
150
163
|
const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
|
|
@@ -153,11 +166,12 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
153
166
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
154
167
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
155
168
|
const restoredRange = changedRange(currentNormalized, undo.content);
|
|
156
|
-
const
|
|
169
|
+
const undoDiffResult = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes);
|
|
170
|
+
const undoDiff = undoDiffResult.diff;
|
|
157
171
|
|
|
158
172
|
try {
|
|
159
173
|
const store = await loadHashStore();
|
|
160
|
-
|
|
174
|
+
persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes);
|
|
161
175
|
recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
|
|
162
176
|
} catch (error) {
|
|
163
177
|
console.error("Failed to restore hash store snapshot after undo:", error);
|
|
@@ -190,6 +204,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
190
204
|
],
|
|
191
205
|
details: {
|
|
192
206
|
diff: undoDiff,
|
|
207
|
+
diffLineNumbers: undoDiffResult.lineNumbers,
|
|
193
208
|
patch: patchResult.patch,
|
|
194
209
|
...(patchResult.truncated ? { patchTruncated: true as const } : {}),
|
|
195
210
|
metrics: buildMetrics({
|
package/src/replace.ts
CHANGED
|
@@ -2,17 +2,16 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ToolDefinition,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Type } from "typebox";
|
|
7
5
|
import { constants } from "fs";
|
|
8
6
|
import {
|
|
9
7
|
genDiff,
|
|
10
8
|
type LineEnding,
|
|
11
9
|
} from "./replace-diff";
|
|
12
10
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
13
|
-
import { normReq } from "./
|
|
14
|
-
import { isRec
|
|
15
|
-
import {
|
|
11
|
+
import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
12
|
+
import { isRec } from "./utils";
|
|
13
|
+
import { loadP, loadGuide } from "./prompts";
|
|
14
|
+
import { type FileIdentity } from "./fs-write";
|
|
16
15
|
import { applyEdit,
|
|
17
16
|
lineHashes,
|
|
18
17
|
resEdit,
|
|
@@ -23,54 +22,18 @@ import { applyEdit,
|
|
|
23
22
|
type HEdit,
|
|
24
23
|
type NEdit,
|
|
25
24
|
} from "./hashline";
|
|
26
|
-
import {
|
|
25
|
+
import { commitEdit } from "./commit";
|
|
27
26
|
import type { RMetrics } from "./replace-response";
|
|
28
27
|
import {
|
|
29
|
-
makeRenderCall,
|
|
30
|
-
renderEditResult,
|
|
31
28
|
type RPreview,
|
|
32
29
|
type RRState,
|
|
33
30
|
} from "./replace-render";
|
|
34
|
-
import { loadP, loadGuide } from "./prompts";
|
|
35
31
|
import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
|
|
36
32
|
import { getServed, recordServedSafe } from "./served";
|
|
37
33
|
import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
const replacementLinesSchema = Type.Array(
|
|
41
|
-
Type.String({
|
|
42
|
-
description:
|
|
43
|
-
"One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
|
|
44
|
-
}),
|
|
45
|
-
{
|
|
46
|
-
description:
|
|
47
|
-
"Replacement lines as an array of strings, one element per line. Use [] to delete the range."
|
|
48
|
-
}
|
|
49
|
-
);
|
|
34
|
+
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
|
|
50
35
|
|
|
51
|
-
|
|
52
|
-
description: "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const removeToSchema = Type.String({
|
|
56
|
-
description: "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
export const editToolSchema = Type.Object(
|
|
60
|
-
{
|
|
61
|
-
path: Type.Optional(Type.String({ description: "Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
|
|
62
|
-
remove_from: removeFromSchema,
|
|
63
|
-
remove_to: removeToSchema,
|
|
64
|
-
replacement_lines: replacementLinesSchema,
|
|
65
|
-
},
|
|
66
|
-
{ additionalProperties: false },
|
|
67
|
-
);
|
|
68
|
-
export type ReqParams = {
|
|
69
|
-
path: string;
|
|
70
|
-
remove_from: string;
|
|
71
|
-
remove_to: string;
|
|
72
|
-
replacement_lines: string[];
|
|
73
|
-
};
|
|
36
|
+
export { editToolSchema, type ReqParams, assertReq };
|
|
74
37
|
|
|
75
38
|
export type ReplaceDetails = {
|
|
76
39
|
diff: string;
|
|
@@ -80,6 +43,7 @@ export type ReplaceDetails = {
|
|
|
80
43
|
snapshotId?: string;
|
|
81
44
|
classification?: "noop";
|
|
82
45
|
metrics?: RMetrics;
|
|
46
|
+
diffLineNumbers?: (number|undefined)[];
|
|
83
47
|
};
|
|
84
48
|
|
|
85
49
|
export interface PipelineResult {
|
|
@@ -99,33 +63,7 @@ export interface PipelineResult {
|
|
|
99
63
|
totalRemovedLines: number;
|
|
100
64
|
hadBoundaryDedup: boolean;
|
|
101
65
|
boundaryRemovedLines: number;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
105
|
-
|
|
106
|
-
export function assertReq(
|
|
107
|
-
request: unknown,
|
|
108
|
-
): asserts request is ReqParams {
|
|
109
|
-
if (!isRec(request)) {
|
|
110
|
-
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
114
|
-
|
|
115
|
-
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
116
|
-
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
typeof request.remove_from !== "string" ||
|
|
121
|
-
typeof request.remove_to !== "string" ||
|
|
122
|
-
!Array.isArray(request.replacement_lines) ||
|
|
123
|
-
request.replacement_lines.some((line) => typeof line !== "string")
|
|
124
|
-
) {
|
|
125
|
-
throw new Error(
|
|
126
|
-
'[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
|
|
127
|
-
);
|
|
128
|
-
}
|
|
66
|
+
identity: FileIdentity;
|
|
129
67
|
}
|
|
130
68
|
|
|
131
69
|
async function resolveMissingPath(
|
|
@@ -173,21 +111,26 @@ export interface ExecPipelineOptions {
|
|
|
173
111
|
preloadedNorm?: NormFile;
|
|
174
112
|
}
|
|
175
113
|
|
|
114
|
+
function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
|
|
115
|
+
const a = hashes.indexOf(from);
|
|
116
|
+
const b = hashes.indexOf(to);
|
|
117
|
+
if (a < 0 || b < 0) return undefined;
|
|
118
|
+
return [Math.min(a, b), Math.max(a, b)];
|
|
119
|
+
}
|
|
120
|
+
async function noteAnchorError(absolutePath: string, error: unknown, scopeHashes: string[], noPersist?: boolean): Promise<void> {
|
|
121
|
+
if (noPersist === true) return;
|
|
122
|
+
if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(scopeHashes));
|
|
123
|
+
else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(scopeHashes));
|
|
124
|
+
}
|
|
125
|
+
|
|
176
126
|
function collectRemovedHashes(
|
|
177
127
|
edit: HEdit,
|
|
178
128
|
originalHashes: string[],
|
|
179
129
|
): Set<string> {
|
|
130
|
+
const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
|
|
180
131
|
const removedHashes = new Set<string>();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const startLine = originalHashes.indexOf(startHash);
|
|
184
|
-
const endLine = originalHashes.indexOf(endHash);
|
|
185
|
-
if (startLine >= 0 && endLine >= 0) {
|
|
186
|
-
const firstLine = Math.min(startLine, endLine);
|
|
187
|
-
const lastLine = Math.max(startLine, endLine);
|
|
188
|
-
for (let i = firstLine; i <= lastLine; i++) {
|
|
189
|
-
removedHashes.add(originalHashes[i]!);
|
|
190
|
-
}
|
|
132
|
+
if (span) {
|
|
133
|
+
for (let i = span[0]; i <= span[1]; i++) removedHashes.add(originalHashes[i]!);
|
|
191
134
|
}
|
|
192
135
|
return removedHashes;
|
|
193
136
|
}
|
|
@@ -199,12 +142,8 @@ function countLineChanges(
|
|
|
199
142
|
removedAutoFixes: number,
|
|
200
143
|
): { totalAddedLines: number; totalRemovedLines: number } {
|
|
201
144
|
if (isNoop) return { totalAddedLines: 0, totalRemovedLines: 0 };
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const endLine = originalHashes.indexOf(edit.hash_bounds[1].hash);
|
|
205
|
-
if (startLine >= 0 && endLine >= 0) {
|
|
206
|
-
totalRemovedLines = Math.abs(endLine - startLine) + 1;
|
|
207
|
-
}
|
|
145
|
+
const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
|
|
146
|
+
const totalRemovedLines = span ? span[1] - span[0] + 1 : 0;
|
|
208
147
|
return {
|
|
209
148
|
totalAddedLines: Math.max(0, edit.content_lines.length - removedAutoFixes),
|
|
210
149
|
totalRemovedLines,
|
|
@@ -230,7 +169,7 @@ export async function execPipeline(
|
|
|
230
169
|
);
|
|
231
170
|
|
|
232
171
|
const hashStore = options?.store ?? await loadHashStore();
|
|
233
|
-
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
|
|
172
|
+
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
|
|
234
173
|
path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, preloadedNorm: options?.preloadedNorm },
|
|
235
174
|
);
|
|
236
175
|
|
|
@@ -247,13 +186,7 @@ export async function execPipeline(
|
|
|
247
186
|
options?.skipBoundaryDedup,
|
|
248
187
|
);
|
|
249
188
|
} catch (error) {
|
|
250
|
-
|
|
251
|
-
if (error instanceof RangeStaleError) {
|
|
252
|
-
await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(originalHashes));
|
|
253
|
-
} else if (error instanceof AnchorMismatchError) {
|
|
254
|
-
await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(originalHashes));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
189
|
+
await noteAnchorError(absolutePath, error, originalHashes, options?.noPersist);
|
|
257
190
|
throw error;
|
|
258
191
|
}
|
|
259
192
|
|
|
@@ -293,9 +226,21 @@ export async function execPipeline(
|
|
|
293
226
|
totalRemovedLines,
|
|
294
227
|
hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
|
|
295
228
|
boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
|
|
229
|
+
identity,
|
|
296
230
|
};
|
|
297
231
|
}
|
|
298
232
|
|
|
233
|
+
export function previewFromPipe(pipe: PipelineResult): RPreview {
|
|
234
|
+
if (pipe.originalNormalized === pipe.result) {
|
|
235
|
+
return {
|
|
236
|
+
error: `No changes made to ${pipe.path}. The edit produced identical content.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
|
|
240
|
+
}
|
|
241
|
+
export function previewError(error: unknown): RPreview {
|
|
242
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
243
|
+
}
|
|
299
244
|
export async function compPreview(
|
|
300
245
|
request: unknown,
|
|
301
246
|
cwd: string,
|
|
@@ -303,20 +248,14 @@ export async function compPreview(
|
|
|
303
248
|
try {
|
|
304
249
|
const normalized = normReq(request);
|
|
305
250
|
assertReq(normalized);
|
|
306
|
-
const
|
|
251
|
+
const pipe = await execPipeline(
|
|
307
252
|
normalized,
|
|
308
253
|
cwd,
|
|
309
254
|
{ accessMode: constants.R_OK, noPersist: true },
|
|
310
255
|
);
|
|
311
|
-
|
|
312
|
-
return {
|
|
313
|
-
error: `No changes made to ${path}. The edit produced identical content.`,
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return { diff: genDiff(originalNormalized, result, 4, resultHashes, originalHashes).diff };
|
|
256
|
+
return previewFromPipe(pipe);
|
|
318
257
|
} catch (error: unknown) {
|
|
319
|
-
return
|
|
258
|
+
return previewError(error);
|
|
320
259
|
}
|
|
321
260
|
}
|
|
322
261
|
|
|
@@ -326,12 +265,10 @@ type ToolDef = ToolDefinition<
|
|
|
326
265
|
RRState
|
|
327
266
|
> & { renderShell?: "default" | "self" };
|
|
328
267
|
|
|
329
|
-
|
|
330
268
|
export function buildToolDef(): ToolDef {
|
|
331
269
|
const E_DESC = loadP("../prompts/replace.md");
|
|
332
270
|
const E_SNIPPET = loadP("../prompts/replace-snippet.md");
|
|
333
271
|
const E_GUIDE = loadGuide("../prompts/replace-guidelines.md");
|
|
334
|
-
|
|
335
272
|
const parameters = editToolSchema;
|
|
336
273
|
return {
|
|
337
274
|
name: "replace",
|
|
@@ -340,21 +277,9 @@ export function buildToolDef(): ToolDef {
|
|
|
340
277
|
parameters,
|
|
341
278
|
promptSnippet: E_SNIPPET,
|
|
342
279
|
promptGuidelines: E_GUIDE,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
347
|
-
return renderEditResult(
|
|
348
|
-
result as {
|
|
349
|
-
content?: Array<{ type: string; text?: string }>;
|
|
350
|
-
details?: ReplaceDetails;
|
|
351
|
-
},
|
|
352
|
-
isPartial,
|
|
353
|
-
theme,
|
|
354
|
-
context,
|
|
355
|
-
);
|
|
356
|
-
},
|
|
357
|
-
|
|
280
|
+
...editToolBase,
|
|
281
|
+
renderCall: editRenderCallWrapper(compPreview),
|
|
282
|
+
renderResult: editRenderResultWrapper,
|
|
358
283
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
359
284
|
const canonical = normReq(params);
|
|
360
285
|
const resolution = isRec(canonical) ? await resolveMissingPath(canonical) : undefined;
|
|
@@ -362,15 +287,11 @@ export function buildToolDef(): ToolDef {
|
|
|
362
287
|
canonical.path = resolution.path;
|
|
363
288
|
}
|
|
364
289
|
assertReq(canonical);
|
|
365
|
-
|
|
366
290
|
const normalizedParams = canonical;
|
|
367
291
|
const path = normalizedParams.path;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
|
|
372
|
-
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
373
|
-
abortIf(signal);
|
|
292
|
+
return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
293
|
+
const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
|
|
294
|
+
const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
|
|
374
295
|
const pipe = await execPipeline(
|
|
375
296
|
normalizedParams,
|
|
376
297
|
ctx.cwd,
|
package/src/served.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadHashStore, parseStoredHashes, type HashStore } from "./hash-store";
|
|
1
|
+
import { loadHashStore, parseStoredHashes, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
|
|
2
2
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
3
|
|
|
4
4
|
const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
|
|
@@ -19,14 +19,14 @@ export function getServed(store: HashStore, path: string): Set<string> | undefin
|
|
|
19
19
|
return new Set(parsed);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
function computeUpdate(
|
|
23
23
|
store: HashStore,
|
|
24
24
|
path: string,
|
|
25
25
|
hashes: string[],
|
|
26
26
|
scope?: ReadonlySet<string>,
|
|
27
|
-
):
|
|
27
|
+
): Set<string> | undefined {
|
|
28
28
|
const existing = getServed(store, path);
|
|
29
|
-
if (!existing && hashes.length === 0) return;
|
|
29
|
+
if (!existing && hashes.length === 0) return undefined;
|
|
30
30
|
const set = existing ?? new Set<string>();
|
|
31
31
|
let changed = false;
|
|
32
32
|
if (scope) {
|
|
@@ -43,7 +43,28 @@ export function recordServed(
|
|
|
43
43
|
changed = true;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
if (!changed) return;
|
|
46
|
+
if (!changed) return undefined;
|
|
47
|
+
return set;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function recordServed(
|
|
51
|
+
store: HashStore,
|
|
52
|
+
path: string,
|
|
53
|
+
hashes: string[],
|
|
54
|
+
scope?: ReadonlySet<string>,
|
|
55
|
+
): void {
|
|
56
|
+
try {
|
|
57
|
+
withStore(() => {
|
|
58
|
+
const set = computeUpdate(store, path, hashes, scope);
|
|
59
|
+
if (!set) return;
|
|
60
|
+
store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!(error instanceof Error && error.message === STORE_NOT_OPEN_MESSAGE)) throw error;
|
|
65
|
+
}
|
|
66
|
+
const set = computeUpdate(store, path, hashes, scope);
|
|
67
|
+
if (!set) return;
|
|
47
68
|
store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
|
|
48
69
|
}
|
|
49
70
|
|
package/src/utils.ts
CHANGED
|
@@ -106,7 +106,65 @@ export function truncateToBytes(s: string, maxBytes: number): string {
|
|
|
106
106
|
return out;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
export function getCached<K, V>(map: Map<K, V>, key: K, compute: (key: K) => V): V {
|
|
110
|
+
if (map.has(key)) return map.get(key)!;
|
|
111
|
+
const v = compute(key);
|
|
112
|
+
map.set(key, v);
|
|
113
|
+
return v;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isHashRow(line: string): boolean {
|
|
117
|
+
return /^[A-Za-z0-9]{3}│/.test(line);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function gutterWidth(max: number, fallback: number): number {
|
|
121
|
+
return String(max || fallback).length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatGutter(n: number, width: number): string {
|
|
125
|
+
return String(n).padStart(width) + " │ ";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function blankGutter(width: number): string {
|
|
129
|
+
return " ".repeat(width) + " │ ";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function numberedRead(text: string, offset: number): string {
|
|
133
|
+
const lines = text.split("\n");
|
|
134
|
+
const hashLines = lines.filter(isHashRow).length;
|
|
135
|
+
const max = hashLines > 0 ? offset + hashLines - 1 : offset;
|
|
136
|
+
const width = gutterWidth(max, offset);
|
|
137
|
+
let n = offset;
|
|
138
|
+
return lines.map((line) => {
|
|
139
|
+
if (!isHashRow(line)) return line;
|
|
140
|
+
const prefix = formatGutter(n++, width);
|
|
141
|
+
return prefix + line;
|
|
142
|
+
}).join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function withLineNumbers(text: string, numbers: (number|undefined)[]): string {
|
|
146
|
+
const lines = text.split("\n");
|
|
147
|
+
const nums = numbers ?? [];
|
|
148
|
+
const max = nums.reduce<number>((m, n) => n !== undefined && n > m ? n : m, 0);
|
|
149
|
+
const width = gutterWidth(max, lines.length);
|
|
150
|
+
return lines.map((line, i) => {
|
|
151
|
+
const n = nums[i];
|
|
152
|
+
const prefix = n !== undefined ? formatGutter(n, width) : blankGutter(width);
|
|
153
|
+
return prefix + line;
|
|
154
|
+
}).join("\n");
|
|
155
|
+
}
|
|
109
156
|
export function clipLine(line: string, maxLen = 200): string {
|
|
110
157
|
const flat = line.replace(/\n/g, "\\n");
|
|
111
158
|
return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
|
|
112
159
|
}
|
|
160
|
+
export function assertLineLimit(content: string, displayPath: string, limit: number): void {
|
|
161
|
+
const count = splitLines(content).length;
|
|
162
|
+
if (count > limit) throw new Error(formatLineLimit(displayPath, limit, count));
|
|
163
|
+
}
|
|
164
|
+
export function lineLimitMoreThanMessage(displayPath: string, limit: number): string {
|
|
165
|
+
return formatLineLimit(displayPath, limit, undefined);
|
|
166
|
+
}
|
|
167
|
+
function formatLineLimit(displayPath: string, limit: number, count: number | undefined): string {
|
|
168
|
+
const detail = count === undefined ? `has more than ${limit}` : `has ${count}`;
|
|
169
|
+
return `[E_FILE_TOO_LARGE] ${displayPath} ${detail} lines, exceeding the ${limit}-line hashline limit. For very large files, use write.`;
|
|
170
|
+
}
|
package/src/validation.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
2
|
import { access as fsAccess } from "fs/promises";
|
|
3
3
|
import type { LFile } from "./file-kind";
|
|
4
|
+
import type { FileIdentity } from "./fs-write";
|
|
4
5
|
import { errCode } from "./utils";
|
|
5
6
|
|
|
6
7
|
export async function valAccess(
|
|
@@ -26,7 +27,7 @@ export async function valAccess(
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
|
|
30
|
+
export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true } {
|
|
30
31
|
if (file.kind === "directory") {
|
|
31
32
|
throw new Error(`[E_NOT_TEXT] Path is a directory: ${path}. Use ls to inspect directories.`);
|
|
32
33
|
}
|
|
@@ -42,4 +43,3 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
|
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
|
+
import { HASH_SEP } from "./hashline/hash";
|
|
4
|
+
import { loadHashStore } from "./hash-store";
|
|
5
|
+
import { getServed } from "./served";
|
|
6
|
+
import { resolveInCwd } from "./fs-write";
|
|
7
|
+
import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
|
|
8
|
+
|
|
9
|
+
const HASH_ECHO_RE = new RegExp(`^(${HASH_CLASS})${HASH_SEP}`);
|
|
10
|
+
|
|
11
|
+
function searchEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
12
|
+
for (let i = 0; i < lines.length; i++) {
|
|
13
|
+
const match = HASH_ECHO_RE.exec(lines[i]!);
|
|
14
|
+
if (match && served.has(match[1]!)) return { line: i + 1, hash: match[1]! };
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function findServedHashEcho(content: string, served: ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
20
|
+
return searchEcho(splitLines(content), served);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function findEditHashEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
24
|
+
return searchEcho(lines, served);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function servedHashEchoDenial(rawPath: string, content: string, cwd: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
28
|
+
abortIf(signal);
|
|
29
|
+
const { resolved } = await resolveInCwd(rawPath, cwd);
|
|
30
|
+
abortIf(signal);
|
|
31
|
+
const store = await loadHashStore();
|
|
32
|
+
const served = getServed(store, resolved);
|
|
33
|
+
if (!served || served.size === 0) return undefined;
|
|
34
|
+
const echo = findServedHashEcho(content, served);
|
|
35
|
+
if (!echo) return undefined;
|
|
36
|
+
return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line} begins with the exact ${echo.hash}${HASH_SEP} anchor served for this file. Remove the copied anchors and retry. Nothing was written.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerWriteHook(pi: ExtensionAPI): void {
|
|
40
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
41
|
+
if (event.toolName !== "write") return;
|
|
42
|
+
const input = event.input as Record<string, unknown> | undefined;
|
|
43
|
+
if (!input || !isRec(input)) return;
|
|
44
|
+
const normalized = { ...input };
|
|
45
|
+
normalizeFilePath(normalized);
|
|
46
|
+
const rawPath = normalized.path as unknown;
|
|
47
|
+
const content = normalized.content as unknown;
|
|
48
|
+
if (typeof rawPath !== "string" || typeof content !== "string") return;
|
|
49
|
+
const signal = ctx.signal;
|
|
50
|
+
try {
|
|
51
|
+
const reason = await servedHashEchoDenial(rawPath, content, ctx.cwd, signal);
|
|
52
|
+
if (reason !== undefined) return { block: true, reason };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (signal?.aborted) throw error;
|
|
55
|
+
console.error("write hook failed:", error);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/replace-normalize.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { isRec, normalizeFilePath } from "./utils";
|
|
2
|
-
|
|
3
|
-
export function normReq(input: unknown): unknown {
|
|
4
|
-
if (!isRec(input)) {
|
|
5
|
-
return input;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const record: Record<string, unknown> = { ...input };
|
|
9
|
-
|
|
10
|
-
normalizeFilePath(record);
|
|
11
|
-
|
|
12
|
-
return record;
|
|
13
|
-
}
|