pi-hashline-edit-pro 2.7.1 → 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 +35 -34
- package/index.ts +11 -9
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +3 -6
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +3 -6
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +2 -2
- package/prompts/read-snippet.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -8
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -3
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/constants.ts +4 -2
- 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 +137 -43
- package/src/hash-store.ts +57 -17
- package/src/hashline/hash.ts +16 -9
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +17 -3
- package/src/hashline/resolve.ts +9 -8
- package/src/insert.ts +11 -16
- package/src/payload-contract.ts +102 -0
- package/src/read.ts +32 -11
- package/src/replace-diff.ts +108 -19
- package/src/replace-render.ts +47 -43
- package/src/replace-response.ts +4 -1
- package/src/replace-undo.ts +17 -12
- package/src/replace.ts +44 -106
- package/src/served.ts +26 -5
- package/src/utils.ts +71 -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
|
-
import type
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
2
|
+
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
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,
|
|
@@ -145,20 +119,47 @@ export function isApplied(
|
|
|
145
119
|
);
|
|
146
120
|
}
|
|
147
121
|
|
|
122
|
+
const RESULT_PREVIEW_LINES = 16;
|
|
123
|
+
|
|
124
|
+
function expandHint(): string {
|
|
125
|
+
try {
|
|
126
|
+
return keyHint("app.tools.expand", "to expand");
|
|
127
|
+
} catch {
|
|
128
|
+
return "ctrl+o to expand";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function extractSummary(text: string | undefined): string | undefined {
|
|
133
|
+
if (!text) return undefined;
|
|
134
|
+
if (text.includes("│")) return undefined;
|
|
135
|
+
const warningsIdx = text.indexOf("\n\nWarnings:");
|
|
136
|
+
const summary = warningsIdx >= 0 ? text.slice(0, warningsIdx) : text;
|
|
137
|
+
return summary.length > 0 ? summary : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
148
140
|
export function buildAppliedText(
|
|
149
141
|
text: string | undefined,
|
|
150
142
|
details: ReplaceDetails | undefined,
|
|
151
143
|
theme: FgT,
|
|
144
|
+
expanded: boolean,
|
|
152
145
|
): string | undefined {
|
|
153
146
|
const sections: string[] = [];
|
|
154
|
-
|
|
147
|
+
const summary = extractSummary(text);
|
|
148
|
+
if (summary) sections.push(summary);
|
|
155
149
|
if (details?.diff) {
|
|
156
|
-
|
|
150
|
+
const rawDiff = details.diffLineNumbers ? toNumberedDiff(details.diff, details.diffLineNumbers) : details.diff;
|
|
151
|
+
const diffLines = details.diff.split("\n");
|
|
152
|
+
const diffSection = expanded
|
|
153
|
+
? fmtResult(rawDiff, theme)
|
|
154
|
+
: fmtPreview(rawDiff, false, theme);
|
|
155
|
+
const hint =
|
|
156
|
+
!expanded && diffLines.length > RESULT_PREVIEW_LINES
|
|
157
|
+
? ` (${expandHint()})`
|
|
158
|
+
: "";
|
|
159
|
+
sections.push(`${diffSection}${hint}`);
|
|
157
160
|
}
|
|
158
|
-
|
|
159
161
|
const warnings = extractWarnings(text);
|
|
160
162
|
if (warnings) sections.push(warnings);
|
|
161
|
-
|
|
162
163
|
return sections.length > 0 ? sections.join("\n\n") : undefined;
|
|
163
164
|
}
|
|
164
165
|
|
|
@@ -296,10 +297,12 @@ export function makeRenderCall(
|
|
|
296
297
|
|
|
297
298
|
export function renderEditResult(
|
|
298
299
|
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
299
|
-
isPartial: boolean,
|
|
300
|
+
options: { isPartial: boolean; expanded?: boolean } | boolean,
|
|
300
301
|
theme: FgT,
|
|
301
302
|
context: any,
|
|
302
303
|
): Text | Markdown {
|
|
304
|
+
const isPartial = typeof options === "boolean" ? options : options.isPartial;
|
|
305
|
+
const optionsExpanded = typeof options === "boolean" ? undefined : options.expanded;
|
|
303
306
|
if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
|
|
304
307
|
const renderedText = getResultText(result);
|
|
305
308
|
const renderState = context.state as RRState | undefined;
|
|
@@ -317,7 +320,8 @@ export function renderEditResult(
|
|
|
317
320
|
: new Text("", 0, 0);
|
|
318
321
|
}
|
|
319
322
|
if (isApplied(result.details)) {
|
|
320
|
-
const
|
|
323
|
+
const isExpanded = optionsExpanded === true || context.expanded === true;
|
|
324
|
+
const appliedText = buildAppliedText(renderedText, result.details, theme, isExpanded);
|
|
321
325
|
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
322
326
|
}
|
|
323
327
|
if (!renderedText) return new Text("", 0, 0);
|
package/src/replace-response.ts
CHANGED
|
@@ -153,15 +153,18 @@ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
|
|
|
153
153
|
removedLines,
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
const patchResult = genPatch(path, originalNormalized, result);
|
|
156
157
|
return {
|
|
157
158
|
content: [{ type: "text", text }],
|
|
158
159
|
details: {
|
|
159
160
|
diff: diffResult.diff,
|
|
160
|
-
patch:
|
|
161
|
+
patch: patchResult.patch,
|
|
162
|
+
...(patchResult.truncated ? { patchTruncated: true as const } : {}),
|
|
161
163
|
firstChangedLine:
|
|
162
164
|
editMeta.firstChangedLine ?? diffResult.firstChangedLine,
|
|
163
165
|
snapshotId,
|
|
164
166
|
metrics,
|
|
167
|
+
diffLineNumbers: diffResult.lineNumbers,
|
|
165
168
|
},
|
|
166
169
|
};
|
|
167
170
|
}
|
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) {
|
|
@@ -149,15 +150,16 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
149
150
|
|
|
150
151
|
const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
|
|
151
152
|
const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
|
|
152
|
-
const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes);
|
|
153
|
+
const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes, { unlimited: true });
|
|
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);
|
|
@@ -180,6 +182,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
180
182
|
"Call read for fresh anchors.",
|
|
181
183
|
);
|
|
182
184
|
|
|
185
|
+
const patchResult = genPatch(path, currentNormalized, undo.content);
|
|
183
186
|
return {
|
|
184
187
|
content: [
|
|
185
188
|
{
|
|
@@ -189,7 +192,9 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
189
192
|
],
|
|
190
193
|
details: {
|
|
191
194
|
diff: undoDiff,
|
|
192
|
-
|
|
195
|
+
diffLineNumbers: undoDiffResult.lineNumbers,
|
|
196
|
+
patch: patchResult.patch,
|
|
197
|
+
...(patchResult.truncated ? { patchTruncated: true as const } : {}),
|
|
193
198
|
metrics: buildMetrics({
|
|
194
199
|
classification: "applied",
|
|
195
200
|
editsAttempted: 1,
|
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,54 +31,21 @@ 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 HASH only (e.g. \"aB3\"): copy just the hash 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 HASH only (e.g. \"aB3\"): copy just the hash 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;
|
|
77
42
|
patch?: string;
|
|
43
|
+
patchTruncated?: boolean;
|
|
78
44
|
firstChangedLine?: number;
|
|
79
45
|
snapshotId?: string;
|
|
80
46
|
classification?: "noop";
|
|
81
47
|
metrics?: RMetrics;
|
|
48
|
+
diffLineNumbers?: (number|undefined)[];
|
|
82
49
|
};
|
|
83
50
|
|
|
84
51
|
export interface PipelineResult {
|
|
@@ -100,33 +67,6 @@ export interface PipelineResult {
|
|
|
100
67
|
boundaryRemovedLines: number;
|
|
101
68
|
}
|
|
102
69
|
|
|
103
|
-
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
104
|
-
|
|
105
|
-
export function assertReq(
|
|
106
|
-
request: unknown,
|
|
107
|
-
): asserts request is ReqParams {
|
|
108
|
-
if (!isRec(request)) {
|
|
109
|
-
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
113
|
-
|
|
114
|
-
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
115
|
-
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
typeof request.remove_from !== "string" ||
|
|
120
|
-
typeof request.remove_to !== "string" ||
|
|
121
|
-
!Array.isArray(request.replacement_lines) ||
|
|
122
|
-
request.replacement_lines.some((line) => typeof line !== "string")
|
|
123
|
-
) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
'[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
70
|
async function resolveMissingPath(
|
|
131
71
|
request: Record<string, unknown>,
|
|
132
72
|
): Promise<{ path: string; warning: string } | undefined> {
|
|
@@ -172,21 +112,26 @@ export interface ExecPipelineOptions {
|
|
|
172
112
|
preloadedNorm?: NormFile;
|
|
173
113
|
}
|
|
174
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
|
+
|
|
175
127
|
function collectRemovedHashes(
|
|
176
128
|
edit: HEdit,
|
|
177
129
|
originalHashes: string[],
|
|
178
130
|
): Set<string> {
|
|
131
|
+
const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
|
|
179
132
|
const removedHashes = new Set<string>();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const startLine = originalHashes.indexOf(startHash);
|
|
183
|
-
const endLine = originalHashes.indexOf(endHash);
|
|
184
|
-
if (startLine >= 0 && endLine >= 0) {
|
|
185
|
-
const firstLine = Math.min(startLine, endLine);
|
|
186
|
-
const lastLine = Math.max(startLine, endLine);
|
|
187
|
-
for (let i = firstLine; i <= lastLine; i++) {
|
|
188
|
-
removedHashes.add(originalHashes[i]!);
|
|
189
|
-
}
|
|
133
|
+
if (span) {
|
|
134
|
+
for (let i = span[0]; i <= span[1]; i++) removedHashes.add(originalHashes[i]!);
|
|
190
135
|
}
|
|
191
136
|
return removedHashes;
|
|
192
137
|
}
|
|
@@ -198,12 +143,8 @@ function countLineChanges(
|
|
|
198
143
|
removedAutoFixes: number,
|
|
199
144
|
): { totalAddedLines: number; totalRemovedLines: number } {
|
|
200
145
|
if (isNoop) return { totalAddedLines: 0, totalRemovedLines: 0 };
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
const endLine = originalHashes.indexOf(edit.hash_bounds[1].hash);
|
|
204
|
-
if (startLine >= 0 && endLine >= 0) {
|
|
205
|
-
totalRemovedLines = Math.abs(endLine - startLine) + 1;
|
|
206
|
-
}
|
|
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;
|
|
207
148
|
return {
|
|
208
149
|
totalAddedLines: Math.max(0, edit.content_lines.length - removedAutoFixes),
|
|
209
150
|
totalRemovedLines,
|
|
@@ -246,13 +187,7 @@ export async function execPipeline(
|
|
|
246
187
|
options?.skipBoundaryDedup,
|
|
247
188
|
);
|
|
248
189
|
} catch (error) {
|
|
249
|
-
|
|
250
|
-
if (error instanceof RangeStaleError) {
|
|
251
|
-
await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(originalHashes));
|
|
252
|
-
} else if (error instanceof AnchorMismatchError) {
|
|
253
|
-
await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(originalHashes));
|
|
254
|
-
}
|
|
255
|
-
}
|
|
190
|
+
await noteAnchorError(absolutePath, error, originalHashes, options?.noPersist);
|
|
256
191
|
throw error;
|
|
257
192
|
}
|
|
258
193
|
|
|
@@ -295,6 +230,17 @@ export async function execPipeline(
|
|
|
295
230
|
};
|
|
296
231
|
}
|
|
297
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
|
+
}
|
|
298
244
|
export async function compPreview(
|
|
299
245
|
request: unknown,
|
|
300
246
|
cwd: string,
|
|
@@ -302,20 +248,14 @@ export async function compPreview(
|
|
|
302
248
|
try {
|
|
303
249
|
const normalized = normReq(request);
|
|
304
250
|
assertReq(normalized);
|
|
305
|
-
const
|
|
251
|
+
const pipe = await execPipeline(
|
|
306
252
|
normalized,
|
|
307
253
|
cwd,
|
|
308
254
|
{ accessMode: constants.R_OK, noPersist: true },
|
|
309
255
|
);
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
error: `No changes made to ${path}. The edit produced identical content.`,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
return { diff: genDiff(originalNormalized, result, 4, resultHashes, originalHashes).diff };
|
|
256
|
+
return previewFromPipe(pipe);
|
|
317
257
|
} catch (error: unknown) {
|
|
318
|
-
return
|
|
258
|
+
return previewError(error);
|
|
319
259
|
}
|
|
320
260
|
}
|
|
321
261
|
|
|
@@ -325,12 +265,10 @@ type ToolDef = ToolDefinition<
|
|
|
325
265
|
RRState
|
|
326
266
|
> & { renderShell?: "default" | "self" };
|
|
327
267
|
|
|
328
|
-
|
|
329
268
|
export function buildToolDef(): ToolDef {
|
|
330
269
|
const E_DESC = loadP("../prompts/replace.md");
|
|
331
270
|
const E_SNIPPET = loadP("../prompts/replace-snippet.md");
|
|
332
271
|
const E_GUIDE = loadGuide("../prompts/replace-guidelines.md");
|
|
333
|
-
|
|
334
272
|
const parameters = editToolSchema;
|
|
335
273
|
return {
|
|
336
274
|
name: "replace",
|
|
@@ -340,15 +278,16 @@ export function buildToolDef(): ToolDef {
|
|
|
340
278
|
promptSnippet: E_SNIPPET,
|
|
341
279
|
promptGuidelines: E_GUIDE,
|
|
342
280
|
prepareArguments: makePrepareArguments(),
|
|
281
|
+
executionMode: "sequential",
|
|
343
282
|
renderShell: "default",
|
|
344
283
|
renderCall: makeRenderCall(compPreview),
|
|
345
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
284
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
346
285
|
return renderEditResult(
|
|
347
286
|
result as {
|
|
348
287
|
content?: Array<{ type: string; text?: string }>;
|
|
349
288
|
details?: ReplaceDetails;
|
|
350
289
|
},
|
|
351
|
-
isPartial,
|
|
290
|
+
{ isPartial, expanded },
|
|
352
291
|
theme,
|
|
353
292
|
context,
|
|
354
293
|
);
|
|
@@ -364,8 +303,7 @@ export function buildToolDef(): ToolDef {
|
|
|
364
303
|
|
|
365
304
|
const normalizedParams = canonical;
|
|
366
305
|
const path = normalizedParams.path;
|
|
367
|
-
const absolutePath =
|
|
368
|
-
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
306
|
+
const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
|
|
369
307
|
const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
|
|
370
308
|
const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
|
|
371
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
|
@@ -93,7 +93,78 @@ export function firstNonEmpty(lines: string[]): string | undefined {
|
|
|
93
93
|
return idx >= 0 ? lines[idx] : undefined;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
export function truncateToBytes(s: string, maxBytes: number): string {
|
|
97
|
+
if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
|
|
98
|
+
let out = "";
|
|
99
|
+
let bytes = 0;
|
|
100
|
+
for (const ch of s) {
|
|
101
|
+
const chBytes = Buffer.byteLength(ch, "utf-8");
|
|
102
|
+
if (bytes + chBytes > maxBytes) break;
|
|
103
|
+
out += ch;
|
|
104
|
+
bytes += chBytes;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
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
|
+
}
|
|
96
156
|
export function clipLine(line: string, maxLen = 200): string {
|
|
97
157
|
const flat = line.replace(/\n/g, "\\n");
|
|
98
158
|
return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
|
|
99
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
|
+
}
|