pi-hashline-edit-pro 2.7.2 → 2.8.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/README.md +2 -1
- package/index.ts +9 -6
- package/package.json +2 -2
- 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/file-kind.ts +8 -6
- package/src/file-reader.ts +25 -11
- package/src/fs-write.ts +28 -2
- package/src/grep.ts +18 -28
- package/src/hash-store.ts +52 -16
- 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 +9 -14
- package/src/payload-contract.ts +102 -0
- package/src/read.ts +15 -1
- package/src/replace-diff.ts +26 -21
- package/src/replace-render.ts +18 -40
- package/src/replace-response.ts +1 -0
- package/src/replace-undo.ts +13 -10
- package/src/replace.ts +43 -106
- package/src/served.ts +26 -5
- package/src/utils.ts +58 -0
- package/src/write-hook.ts +59 -0
- package/src/replace-normalize.ts +0 -13
package/src/replace-render.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
2
|
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
3
|
+
import type { ReplaceDetails } from "./replace";
|
|
4
|
+
import { withLineNumbers } from "./utils";
|
|
5
|
+
import { getPreviewInput } from "./payload-contract";
|
|
6
|
+
export { getPreviewInput };
|
|
6
7
|
|
|
7
8
|
export type FgT = Pick<Theme, "fg">;
|
|
8
9
|
export type CallT = Pick<Theme, "fg" | "bold">;
|
|
@@ -20,42 +21,12 @@ export type RRState = {
|
|
|
20
21
|
previewTimer?: ReturnType<typeof setTimeout>;
|
|
21
22
|
};
|
|
22
23
|
|
|
23
|
-
export function getPreviewInput(
|
|
24
|
-
args: unknown,
|
|
25
|
-
): ReqParams | null {
|
|
26
|
-
let normalized: unknown;
|
|
27
|
-
try {
|
|
28
|
-
normalized = normReq(args);
|
|
29
|
-
} catch {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
if (!isRec(normalized) || typeof normalized.path !== "string") {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
typeof normalized.remove_from !== "string" ||
|
|
38
|
-
typeof normalized.remove_to !== "string" ||
|
|
39
|
-
!Array.isArray(normalized.replacement_lines) ||
|
|
40
|
-
normalized.replacement_lines.some((line) => typeof line !== "string")
|
|
41
|
-
) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const request: ReqParams = {
|
|
46
|
-
path: normalized.path,
|
|
47
|
-
remove_from: normalized.remove_from,
|
|
48
|
-
remove_to: normalized.remove_to,
|
|
49
|
-
replacement_lines: normalized.replacement_lines,
|
|
50
|
-
};
|
|
51
|
-
return request;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
24
|
type DiffRowKind = "added" | "removed" | "context";
|
|
55
25
|
|
|
56
26
|
function diffRowKind(line: string): DiffRowKind {
|
|
57
|
-
|
|
58
|
-
if (
|
|
27
|
+
const stripped = line.replace(/^\s*\d+\s+│\s*/, "");
|
|
28
|
+
if (stripped.startsWith("+") && !stripped.startsWith("+++")) return "added";
|
|
29
|
+
if (stripped.startsWith("-") && !stripped.startsWith("---")) return "removed";
|
|
59
30
|
return "context";
|
|
60
31
|
}
|
|
61
32
|
|
|
@@ -67,6 +38,9 @@ export function colorLines(lines: string[], theme: FgT): string[] {
|
|
|
67
38
|
return theme.fg("dim", line);
|
|
68
39
|
});
|
|
69
40
|
}
|
|
41
|
+
export function toNumberedDiff(diff: string, lineNumbers: (number|undefined)[]): string {
|
|
42
|
+
return withLineNumbers(diff, lineNumbers);
|
|
43
|
+
}
|
|
70
44
|
|
|
71
45
|
export function fmtPreview(
|
|
72
46
|
diff: string,
|
|
@@ -173,10 +147,11 @@ export function buildAppliedText(
|
|
|
173
147
|
const summary = extractSummary(text);
|
|
174
148
|
if (summary) sections.push(summary);
|
|
175
149
|
if (details?.diff) {
|
|
150
|
+
const rawDiff = details.diffLineNumbers ? toNumberedDiff(details.diff, details.diffLineNumbers) : details.diff;
|
|
176
151
|
const diffLines = details.diff.split("\n");
|
|
177
152
|
const diffSection = expanded
|
|
178
|
-
? fmtResult(
|
|
179
|
-
: fmtPreview(
|
|
153
|
+
? fmtResult(rawDiff, theme)
|
|
154
|
+
: fmtPreview(rawDiff, false, theme);
|
|
180
155
|
const hint =
|
|
181
156
|
!expanded && diffLines.length > RESULT_PREVIEW_LINES
|
|
182
157
|
? ` (${expandHint()})`
|
|
@@ -322,10 +297,12 @@ export function makeRenderCall(
|
|
|
322
297
|
|
|
323
298
|
export function renderEditResult(
|
|
324
299
|
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
325
|
-
isPartial: boolean,
|
|
300
|
+
options: { isPartial: boolean; expanded?: boolean } | boolean,
|
|
326
301
|
theme: FgT,
|
|
327
302
|
context: any,
|
|
328
303
|
): Text | Markdown {
|
|
304
|
+
const isPartial = typeof options === "boolean" ? options : options.isPartial;
|
|
305
|
+
const optionsExpanded = typeof options === "boolean" ? undefined : options.expanded;
|
|
329
306
|
if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
|
|
330
307
|
const renderedText = getResultText(result);
|
|
331
308
|
const renderState = context.state as RRState | undefined;
|
|
@@ -343,7 +320,8 @@ export function renderEditResult(
|
|
|
343
320
|
: new Text("", 0, 0);
|
|
344
321
|
}
|
|
345
322
|
if (isApplied(result.details)) {
|
|
346
|
-
const
|
|
323
|
+
const isExpanded = optionsExpanded === true || context.expanded === true;
|
|
324
|
+
const appliedText = buildAppliedText(renderedText, result.details, theme, isExpanded);
|
|
347
325
|
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
348
326
|
}
|
|
349
327
|
if (!renderedText) return new Text("", 0, 0);
|
package/src/replace-response.ts
CHANGED
package/src/replace-undo.ts
CHANGED
|
@@ -2,15 +2,14 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
-
import { loadHashStore,
|
|
5
|
+
import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
6
6
|
import { recordServedDiff } from "./served";
|
|
7
|
-
import {
|
|
8
|
-
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
9
|
-
import { toCwd } from "./paths";
|
|
7
|
+
import { resolveInCwd, writeAtomic } from "./fs-write";
|
|
10
8
|
import { toLF, stripBOM, genDiff, genPatch, restoreEndings, type LineEnding } from "./replace-diff";
|
|
11
|
-
import { cntDiff,
|
|
9
|
+
import { cntDiff, errCode, makePrepareArguments } from "./utils";
|
|
12
10
|
import { loadP, loadGuide } from "./prompts";
|
|
13
11
|
import { buildMetrics } from "./replace-response";
|
|
12
|
+
import { renderEditResult } from "./replace-render";
|
|
14
13
|
import { changedRange, lineHashes } from "./hashline";
|
|
15
14
|
export interface UndoEntry {
|
|
16
15
|
content: string;
|
|
@@ -98,11 +97,13 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
98
97
|
description: "Path to the file to undo",
|
|
99
98
|
}),
|
|
100
99
|
}),
|
|
101
|
-
|
|
100
|
+
executionMode: "sequential",
|
|
101
|
+
renderResult(result, opts, theme, context) {
|
|
102
|
+
return renderEditResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
|
|
103
|
+
},
|
|
102
104
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
103
105
|
const path = params.path;
|
|
104
|
-
const
|
|
105
|
-
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
106
|
+
const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
|
|
106
107
|
|
|
107
108
|
const undo = await getUndo(mutationTargetPath);
|
|
108
109
|
if (!undo) {
|
|
@@ -153,11 +154,12 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
153
154
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
154
155
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
155
156
|
const restoredRange = changedRange(currentNormalized, undo.content);
|
|
156
|
-
const
|
|
157
|
+
const undoDiffResult = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes);
|
|
158
|
+
const undoDiff = undoDiffResult.diff;
|
|
157
159
|
|
|
158
160
|
try {
|
|
159
161
|
const store = await loadHashStore();
|
|
160
|
-
|
|
162
|
+
persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes);
|
|
161
163
|
recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
|
|
162
164
|
} catch (error) {
|
|
163
165
|
console.error("Failed to restore hash store snapshot after undo:", error);
|
|
@@ -190,6 +192,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
190
192
|
],
|
|
191
193
|
details: {
|
|
192
194
|
diff: undoDiff,
|
|
195
|
+
diffLineNumbers: undoDiffResult.lineNumbers,
|
|
193
196
|
patch: patchResult.patch,
|
|
194
197
|
...(patchResult.truncated ? { patchTruncated: true as const } : {}),
|
|
195
198
|
metrics: buildMetrics({
|
package/src/replace.ts
CHANGED
|
@@ -3,16 +3,16 @@ import type {
|
|
|
3
3
|
ToolDefinition,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Type } from "typebox";
|
|
7
6
|
import { constants } from "fs";
|
|
8
7
|
import {
|
|
9
8
|
genDiff,
|
|
10
9
|
type LineEnding,
|
|
11
10
|
} from "./replace-diff";
|
|
12
11
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
13
|
-
import { normReq } from "./
|
|
14
|
-
import { isRec,
|
|
15
|
-
import {
|
|
12
|
+
import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
|
+
import { isRec, abortIf, makePrepareArguments } from "./utils";
|
|
14
|
+
import { loadP, loadGuide } from "./prompts";
|
|
15
|
+
import { resolveInCwd } from "./fs-write";
|
|
16
16
|
import { applyEdit,
|
|
17
17
|
lineHashes,
|
|
18
18
|
resEdit,
|
|
@@ -23,7 +23,7 @@ import { applyEdit,
|
|
|
23
23
|
type HEdit,
|
|
24
24
|
type NEdit,
|
|
25
25
|
} from "./hashline";
|
|
26
|
-
import {
|
|
26
|
+
import { commitEdit } from "./commit";
|
|
27
27
|
import type { RMetrics } from "./replace-response";
|
|
28
28
|
import {
|
|
29
29
|
makeRenderCall,
|
|
@@ -31,46 +31,11 @@ import {
|
|
|
31
31
|
type RPreview,
|
|
32
32
|
type RRState,
|
|
33
33
|
} from "./replace-render";
|
|
34
|
-
import { loadP, loadGuide } from "./prompts";
|
|
35
34
|
import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
|
|
36
35
|
import { getServed, recordServedSafe } from "./served";
|
|
37
36
|
import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
|
|
38
|
-
import { commitEdit } from "./commit";
|
|
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
|
-
);
|
|
50
|
-
|
|
51
|
-
const removeFromSchema = Type.String({
|
|
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
37
|
|
|
59
|
-
export
|
|
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
|
-
};
|
|
38
|
+
export { editToolSchema, type ReqParams, assertReq };
|
|
74
39
|
|
|
75
40
|
export type ReplaceDetails = {
|
|
76
41
|
diff: string;
|
|
@@ -80,6 +45,7 @@ export type ReplaceDetails = {
|
|
|
80
45
|
snapshotId?: string;
|
|
81
46
|
classification?: "noop";
|
|
82
47
|
metrics?: RMetrics;
|
|
48
|
+
diffLineNumbers?: (number|undefined)[];
|
|
83
49
|
};
|
|
84
50
|
|
|
85
51
|
export interface PipelineResult {
|
|
@@ -101,33 +67,6 @@ export interface PipelineResult {
|
|
|
101
67
|
boundaryRemovedLines: number;
|
|
102
68
|
}
|
|
103
69
|
|
|
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
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
70
|
async function resolveMissingPath(
|
|
132
71
|
request: Record<string, unknown>,
|
|
133
72
|
): Promise<{ path: string; warning: string } | undefined> {
|
|
@@ -173,21 +112,26 @@ export interface ExecPipelineOptions {
|
|
|
173
112
|
preloadedNorm?: NormFile;
|
|
174
113
|
}
|
|
175
114
|
|
|
115
|
+
function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
|
|
116
|
+
const a = hashes.indexOf(from);
|
|
117
|
+
const b = hashes.indexOf(to);
|
|
118
|
+
if (a < 0 || b < 0) return undefined;
|
|
119
|
+
return [Math.min(a, b), Math.max(a, b)];
|
|
120
|
+
}
|
|
121
|
+
async function noteAnchorError(absolutePath: string, error: unknown, scopeHashes: string[], noPersist?: boolean): Promise<void> {
|
|
122
|
+
if (noPersist === true) return;
|
|
123
|
+
if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(scopeHashes));
|
|
124
|
+
else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(scopeHashes));
|
|
125
|
+
}
|
|
126
|
+
|
|
176
127
|
function collectRemovedHashes(
|
|
177
128
|
edit: HEdit,
|
|
178
129
|
originalHashes: string[],
|
|
179
130
|
): Set<string> {
|
|
131
|
+
const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
|
|
180
132
|
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
|
-
}
|
|
133
|
+
if (span) {
|
|
134
|
+
for (let i = span[0]; i <= span[1]; i++) removedHashes.add(originalHashes[i]!);
|
|
191
135
|
}
|
|
192
136
|
return removedHashes;
|
|
193
137
|
}
|
|
@@ -199,12 +143,8 @@ function countLineChanges(
|
|
|
199
143
|
removedAutoFixes: number,
|
|
200
144
|
): { totalAddedLines: number; totalRemovedLines: number } {
|
|
201
145
|
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
|
-
}
|
|
146
|
+
const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
|
|
147
|
+
const totalRemovedLines = span ? span[1] - span[0] + 1 : 0;
|
|
208
148
|
return {
|
|
209
149
|
totalAddedLines: Math.max(0, edit.content_lines.length - removedAutoFixes),
|
|
210
150
|
totalRemovedLines,
|
|
@@ -247,13 +187,7 @@ export async function execPipeline(
|
|
|
247
187
|
options?.skipBoundaryDedup,
|
|
248
188
|
);
|
|
249
189
|
} 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
|
-
}
|
|
190
|
+
await noteAnchorError(absolutePath, error, originalHashes, options?.noPersist);
|
|
257
191
|
throw error;
|
|
258
192
|
}
|
|
259
193
|
|
|
@@ -296,6 +230,17 @@ export async function execPipeline(
|
|
|
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",
|
|
@@ -341,15 +278,16 @@ export function buildToolDef(): ToolDef {
|
|
|
341
278
|
promptSnippet: E_SNIPPET,
|
|
342
279
|
promptGuidelines: E_GUIDE,
|
|
343
280
|
prepareArguments: makePrepareArguments(),
|
|
281
|
+
executionMode: "sequential",
|
|
344
282
|
renderShell: "default",
|
|
345
283
|
renderCall: makeRenderCall(compPreview),
|
|
346
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
284
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
347
285
|
return renderEditResult(
|
|
348
286
|
result as {
|
|
349
287
|
content?: Array<{ type: string; text?: string }>;
|
|
350
288
|
details?: ReplaceDetails;
|
|
351
289
|
},
|
|
352
|
-
isPartial,
|
|
290
|
+
{ isPartial, expanded },
|
|
353
291
|
theme,
|
|
354
292
|
context,
|
|
355
293
|
);
|
|
@@ -365,8 +303,7 @@ export function buildToolDef(): ToolDef {
|
|
|
365
303
|
|
|
366
304
|
const normalizedParams = canonical;
|
|
367
305
|
const path = normalizedParams.path;
|
|
368
|
-
const absolutePath =
|
|
369
|
-
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
306
|
+
const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
|
|
370
307
|
const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
|
|
371
308
|
const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
|
|
372
309
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
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
|
+
}
|
|
@@ -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
|
-
}
|