pi-hashline-edit-pro 2.6.5 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -18
- package/index.ts +9 -4
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +6 -0
- package/prompts/grep-snippet.md +1 -0
- package/prompts/grep.md +1 -0
- package/prompts/insert-guidelines.md +6 -0
- package/prompts/insert-snippet.md +1 -0
- package/prompts/insert.md +1 -0
- package/prompts/read-guidelines.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -0
- package/prompts/undo-last-change-snippet.md +1 -0
- package/prompts/undo-last-change.md +1 -0
- package/src/commit.ts +107 -0
- package/src/file-reader.ts +4 -0
- package/src/grep.ts +323 -0
- package/src/hashline/index.ts +2 -0
- package/src/hashline/resolve.ts +43 -15
- package/src/insert.ts +222 -0
- package/src/replace-diff.ts +47 -6
- package/src/replace-render.ts +117 -2
- package/src/replace-response.ts +5 -5
- package/src/replace-undo.ts +9 -9
- package/src/replace.ts +34 -240
- package/prompts/undo-last-replace-guidelines.md +0 -2
- package/prompts/undo-last-replace-snippet.md +0 -1
- package/prompts/undo-last-replace.md +0 -1
package/src/grep.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { readdir, stat } from "fs/promises";
|
|
4
|
+
import { dirname, join, relative } from "path";
|
|
5
|
+
import { loadFileKindAndText } from "./file-kind";
|
|
6
|
+
import { readNormFile } from "./file-reader";
|
|
7
|
+
import { MAX_HASH_LINES, HASH_SEP } from "./hashline";
|
|
8
|
+
import { toCwd } from "./paths";
|
|
9
|
+
import { loadP, loadGuide } from "./prompts";
|
|
10
|
+
import { normReq } from "./replace-normalize";
|
|
11
|
+
import { recordServedSafe } from "./served";
|
|
12
|
+
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, visLines } from "./utils";
|
|
13
|
+
|
|
14
|
+
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
|
|
16
|
+
const MAX_SCAN_FILES = 4000;
|
|
17
|
+
const MAX_SHOWN_ROWS = 2000;
|
|
18
|
+
|
|
19
|
+
export interface GrepReq {
|
|
20
|
+
pattern: string;
|
|
21
|
+
path?: string;
|
|
22
|
+
glob?: string;
|
|
23
|
+
context?: number;
|
|
24
|
+
ignoreCase?: boolean;
|
|
25
|
+
literal?: boolean;
|
|
26
|
+
limit?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertGrepReq(request: unknown): asserts request is GrepReq {
|
|
30
|
+
if (!isRec(request)) {
|
|
31
|
+
throw new Error("[E_BAD_SHAPE] Grep request must be an object.");
|
|
32
|
+
}
|
|
33
|
+
rejectUnknownFields(request, GREP_KS, "Grep request");
|
|
34
|
+
if (typeof request.pattern !== "string" || request.pattern.length === 0) {
|
|
35
|
+
throw new Error('[E_BAD_SHAPE] Grep request requires a non-empty "pattern" string.');
|
|
36
|
+
}
|
|
37
|
+
if (request.context !== undefined && (typeof request.context !== "number" || !Number.isInteger(request.context) || request.context < 0)) {
|
|
38
|
+
throw new Error('[E_BAD_SHAPE] Grep request field "context" must be a non-negative integer.');
|
|
39
|
+
}
|
|
40
|
+
if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isInteger(request.limit) || request.limit < 1)) {
|
|
41
|
+
throw new Error('[E_BAD_SHAPE] Grep request field "limit" must be a positive integer.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
|
|
46
|
+
const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
|
|
47
|
+
try {
|
|
48
|
+
return new RegExp(source, ignoreCase ? "ui" : "u");
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`[E_BAD_SHAPE] Invalid pattern: ${pattern}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function globToRegex(glob: string): RegExp {
|
|
55
|
+
let source = "";
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < glob.length) {
|
|
58
|
+
const ch = glob[i]!;
|
|
59
|
+
if (ch === "*") {
|
|
60
|
+
if (glob[i + 1] === "*") {
|
|
61
|
+
i += 2;
|
|
62
|
+
if (glob[i] === "/") {
|
|
63
|
+
i += 1;
|
|
64
|
+
source += "(?:.*\\/)?";
|
|
65
|
+
} else {
|
|
66
|
+
source += ".*";
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
source += ".*";
|
|
71
|
+
} else if (ch === "?") {
|
|
72
|
+
source += "[^/]";
|
|
73
|
+
} else {
|
|
74
|
+
source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
75
|
+
}
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
return new RegExp(`^${source}$`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isSkipableLoadError(error: unknown): boolean {
|
|
82
|
+
const code = errCode(error);
|
|
83
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return true;
|
|
84
|
+
return error instanceof Error && error.message.startsWith("[E_FILE_TOO_LARGE]");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface FileHit {
|
|
88
|
+
path: string;
|
|
89
|
+
displayPath: string;
|
|
90
|
+
fileHashes: string[];
|
|
91
|
+
rows: string[];
|
|
92
|
+
hashes: string[];
|
|
93
|
+
matchCount: number;
|
|
94
|
+
totalMatchCount: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ScanState {
|
|
98
|
+
scanned: number;
|
|
99
|
+
stopped: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function walkFiles(
|
|
103
|
+
root: string,
|
|
104
|
+
state: ScanState,
|
|
105
|
+
onFile: (absPath: string) => Promise<void>,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const queue: string[] = [root];
|
|
108
|
+
while (queue.length > 0 && !state.stopped) {
|
|
109
|
+
const dir = queue.pop()!;
|
|
110
|
+
let entries;
|
|
111
|
+
try {
|
|
112
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (state.stopped) break;
|
|
118
|
+
const full = join(dir, entry.name);
|
|
119
|
+
if (entry.isDirectory()) {
|
|
120
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
121
|
+
queue.push(full);
|
|
122
|
+
} else if (entry.isFile()) {
|
|
123
|
+
state.scanned += 1;
|
|
124
|
+
if (state.scanned > MAX_SCAN_FILES) {
|
|
125
|
+
state.stopped = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
await onFile(full);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function searchFile(
|
|
135
|
+
absPath: string,
|
|
136
|
+
globRoot: string,
|
|
137
|
+
cwd: string,
|
|
138
|
+
regex: RegExp,
|
|
139
|
+
globRegex: RegExp | undefined,
|
|
140
|
+
context: number,
|
|
141
|
+
maxMatches: number,
|
|
142
|
+
): Promise<FileHit | undefined> {
|
|
143
|
+
const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
|
|
144
|
+
if (globRegex) {
|
|
145
|
+
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
146
|
+
if (!globRegex.test(globPath)) return undefined;
|
|
147
|
+
}
|
|
148
|
+
let file;
|
|
149
|
+
try {
|
|
150
|
+
file = await loadFileKindAndText(absPath, { maxLines: MAX_HASH_LINES, displayPath });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isSkipableLoadError(error)) return undefined;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
if (file.kind !== "text") return undefined;
|
|
156
|
+
let norm;
|
|
157
|
+
try {
|
|
158
|
+
norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (isSkipableLoadError(error)) return undefined;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
const lines = visLines(norm.normalized);
|
|
164
|
+
const matchLines: number[] = [];
|
|
165
|
+
for (let i = 0; i < lines.length; i++) {
|
|
166
|
+
if (regex.test(lines[i]!)) matchLines.push(i);
|
|
167
|
+
}
|
|
168
|
+
if (matchLines.length === 0) return undefined;
|
|
169
|
+
const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
|
|
170
|
+
const shown = new Set<number>();
|
|
171
|
+
for (const i of keptMatches) {
|
|
172
|
+
for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
|
|
173
|
+
}
|
|
174
|
+
const sorted = [...shown].sort((a, b) => a - b);
|
|
175
|
+
const rows: string[] = [];
|
|
176
|
+
const hashes: string[] = [];
|
|
177
|
+
for (const idx of sorted) {
|
|
178
|
+
rows.push(`${norm.fileHashes[idx]}${HASH_SEP}${lines[idx]}`);
|
|
179
|
+
hashes.push(norm.fileHashes[idx]!);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
path: norm.absolutePath,
|
|
183
|
+
displayPath,
|
|
184
|
+
fileHashes: norm.fileHashes,
|
|
185
|
+
rows,
|
|
186
|
+
hashes,
|
|
187
|
+
matchCount: keptMatches.length,
|
|
188
|
+
totalMatchCount: matchLines.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const grepToolSchema = Type.Object(
|
|
193
|
+
{
|
|
194
|
+
pattern: Type.String({
|
|
195
|
+
description: "Search pattern (regex or literal string)",
|
|
196
|
+
}),
|
|
197
|
+
path: Type.Optional(
|
|
198
|
+
Type.String({
|
|
199
|
+
description: "Directory or file to search (default: current directory)",
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
glob: Type.Optional(
|
|
203
|
+
Type.String({
|
|
204
|
+
description: "Filter files by glob pattern; * matches across directories, e.g. '*.ts' or '**/*.spec.ts'",
|
|
205
|
+
}),
|
|
206
|
+
),
|
|
207
|
+
ignoreCase: Type.Optional(
|
|
208
|
+
Type.Boolean({
|
|
209
|
+
description: "Case-insensitive search (default: false)",
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
literal: Type.Optional(
|
|
213
|
+
Type.Boolean({
|
|
214
|
+
description: "Treat pattern as literal string instead of regex (default: false)",
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
context: Type.Optional(
|
|
218
|
+
Type.Integer({
|
|
219
|
+
minimum: 0,
|
|
220
|
+
description: "Number of lines to show before and after each match (default: 0)",
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
limit: Type.Optional(
|
|
224
|
+
Type.Integer({
|
|
225
|
+
minimum: 1,
|
|
226
|
+
description: "Maximum number of matches to return (default: 100)",
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
{ additionalProperties: false },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
export function regGrep(pi: ExtensionAPI): void {
|
|
234
|
+
pi.registerTool({
|
|
235
|
+
name: "grep",
|
|
236
|
+
label: "Grep",
|
|
237
|
+
description: loadP("../prompts/grep.md"),
|
|
238
|
+
promptSnippet: loadP("../prompts/grep-snippet.md"),
|
|
239
|
+
promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
|
|
240
|
+
prepareArguments: makePrepareArguments(),
|
|
241
|
+
parameters: grepToolSchema,
|
|
242
|
+
|
|
243
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
244
|
+
const canonical = normReq(params);
|
|
245
|
+
assertGrepReq(canonical);
|
|
246
|
+
const req = canonical;
|
|
247
|
+
const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
248
|
+
const context = req.context ?? 0;
|
|
249
|
+
const limit = req.limit ?? 100;
|
|
250
|
+
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
251
|
+
const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
|
|
252
|
+
abortIf(signal);
|
|
253
|
+
let baseStat;
|
|
254
|
+
try {
|
|
255
|
+
baseStat = await stat(base);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (errCode(error) === "ENOENT") {
|
|
258
|
+
throw new Error(`[E_NOT_FOUND] File not found: ${req.path ?? ctx.cwd}`);
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
|
|
261
|
+
}
|
|
262
|
+
const globRoot = baseStat.isFile() ? dirname(base) : base;
|
|
263
|
+
const state: ScanState = { scanned: 0, stopped: false };
|
|
264
|
+
const files: string[] = [];
|
|
265
|
+
if (baseStat.isFile()) {
|
|
266
|
+
files.push(base);
|
|
267
|
+
} else {
|
|
268
|
+
await walkFiles(base, state, async (absPath) => {
|
|
269
|
+
files.push(absPath);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const hits: FileHit[] = [];
|
|
273
|
+
let matches = 0;
|
|
274
|
+
let limitTruncated = false;
|
|
275
|
+
let rowTruncated = false;
|
|
276
|
+
let rowCount = 0;
|
|
277
|
+
for (const absPath of files) {
|
|
278
|
+
abortIf(signal);
|
|
279
|
+
const remaining = limit - matches;
|
|
280
|
+
if (remaining <= 0) {
|
|
281
|
+
limitTruncated = true;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
|
|
285
|
+
if (!hit) continue;
|
|
286
|
+
const rowBudget = MAX_SHOWN_ROWS - rowCount;
|
|
287
|
+
if (rowBudget <= 0) {
|
|
288
|
+
rowTruncated = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
const keptRows = hit.rows.slice(0, rowBudget);
|
|
292
|
+
const keptHashes = hit.hashes.slice(0, rowBudget);
|
|
293
|
+
rowCount += keptRows.length;
|
|
294
|
+
if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
|
|
295
|
+
if (keptRows.length < hit.rows.length) rowTruncated = true;
|
|
296
|
+
matches += hit.matchCount;
|
|
297
|
+
hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
|
|
298
|
+
}
|
|
299
|
+
for (const hit of hits) {
|
|
300
|
+
await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
|
|
301
|
+
}
|
|
302
|
+
const blocks = hits
|
|
303
|
+
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
|
304
|
+
.join("\n");
|
|
305
|
+
const notes: string[] = [];
|
|
306
|
+
if (rowTruncated) notes.push(`[grep: output truncated at ${MAX_SHOWN_ROWS} rows; refine the pattern to see more.]`);
|
|
307
|
+
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
308
|
+
if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
|
|
309
|
+
const truncated = limitTruncated || rowTruncated;
|
|
310
|
+
const text = blocks.length > 0 ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}` : "No matches found.";
|
|
311
|
+
return {
|
|
312
|
+
content: [{ type: "text", text }],
|
|
313
|
+
details: {
|
|
314
|
+
metrics: {
|
|
315
|
+
matches,
|
|
316
|
+
files: hits.length,
|
|
317
|
+
truncated: truncated || state.stopped,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -164,26 +164,29 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
164
164
|
|
|
165
165
|
export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
|
|
166
166
|
|
|
167
|
+
export function stripAnchorRow(
|
|
168
|
+
trimmed: string,
|
|
169
|
+
entryLabel: string,
|
|
170
|
+
warnings?: string[],
|
|
171
|
+
): string {
|
|
172
|
+
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
173
|
+
if (!match) return trimmed;
|
|
174
|
+
const marker =
|
|
175
|
+
match[1] === "+"
|
|
176
|
+
? "diff-preview marker"
|
|
177
|
+
: match[1] === "-"
|
|
178
|
+
? 'leading "-" marker'
|
|
179
|
+
: '"HASH│" prefix';
|
|
180
|
+
warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
|
|
181
|
+
return match[2]!;
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
168
185
|
assertItem(edit as Record<string, unknown>);
|
|
169
186
|
|
|
170
187
|
const replaceLines = parseText(edit.replacement_lines, warnings);
|
|
171
188
|
const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
|
|
172
|
-
|
|
173
|
-
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
174
|
-
if (match) {
|
|
175
|
-
let message: string;
|
|
176
|
-
if (match[1] === "+") {
|
|
177
|
-
message = `[E_BAD_REF] Stripped diff-preview marker from remove_from/remove_to entry "${trimmed}".`;
|
|
178
|
-
} else if (match[1] === "-") {
|
|
179
|
-
message = `[E_BAD_REF] Stripped leading "-" marker from remove_from/remove_to entry "${trimmed}".`;
|
|
180
|
-
} else {
|
|
181
|
-
message = `[E_BAD_REF] Stripped "HASH│" prefix from remove_from/remove_to entry "${trimmed}".`;
|
|
182
|
-
}
|
|
183
|
-
warnings?.push(message);
|
|
184
|
-
return match[2]!;
|
|
185
|
-
}
|
|
186
|
-
return ref;
|
|
189
|
+
return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
|
|
187
190
|
}) as [string, string];
|
|
188
191
|
return {
|
|
189
192
|
content_lines: replaceLines,
|
|
@@ -473,6 +476,31 @@ export function valEdit(
|
|
|
473
476
|
};
|
|
474
477
|
}
|
|
475
478
|
|
|
479
|
+
export function resolveAnchorLine(
|
|
480
|
+
ref: Anchor,
|
|
481
|
+
fileLines: string[],
|
|
482
|
+
fileHashes: string[],
|
|
483
|
+
filePath?: string,
|
|
484
|
+
): number {
|
|
485
|
+
const { resolved, mismatches } = valEdit(
|
|
486
|
+
{ hash_bounds: [ref, ref], content_lines: [] },
|
|
487
|
+
fileLines,
|
|
488
|
+
fileHashes,
|
|
489
|
+
[],
|
|
490
|
+
undefined,
|
|
491
|
+
);
|
|
492
|
+
if (mismatches.length > 0 || !resolved) {
|
|
493
|
+
const feedback = fmtMismatchWithHashes(
|
|
494
|
+
mismatches,
|
|
495
|
+
fileLines,
|
|
496
|
+
fileHashes,
|
|
497
|
+
filePath,
|
|
498
|
+
);
|
|
499
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
500
|
+
}
|
|
501
|
+
return resolved.hash_bounds[0].line;
|
|
502
|
+
}
|
|
503
|
+
|
|
476
504
|
export class RangeStaleError extends Error {
|
|
477
505
|
readonly firstMismatchLine: number;
|
|
478
506
|
readonly rangeHashes: string[];
|
package/src/insert.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { constants } from "fs";
|
|
5
|
+
import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
|
|
6
|
+
import { commitEdit } from "./commit";
|
|
7
|
+
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
+
import { resolveTarget } from "./fs-write";
|
|
9
|
+
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
10
|
+
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
|
+
import { toCwd } from "./paths";
|
|
12
|
+
import { loadP, loadGuide } from "./prompts";
|
|
13
|
+
import { normReq } from "./replace-normalize";
|
|
14
|
+
import { genDiff } from "./replace-diff";
|
|
15
|
+
import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
|
|
16
|
+
import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
|
|
17
|
+
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
18
|
+
|
|
19
|
+
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
20
|
+
|
|
21
|
+
export interface InsertReq {
|
|
22
|
+
path: string;
|
|
23
|
+
anchor: string;
|
|
24
|
+
direction: "before" | "after";
|
|
25
|
+
lines: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertInsertReq(request: unknown): asserts request is InsertReq {
|
|
29
|
+
if (!isRec(request)) {
|
|
30
|
+
throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
|
|
31
|
+
}
|
|
32
|
+
rejectUnknownFields(request, INSERT_KS, "Insert request");
|
|
33
|
+
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
34
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
|
|
35
|
+
}
|
|
36
|
+
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
37
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char hash from read output).');
|
|
38
|
+
}
|
|
39
|
+
if (request.direction !== "before" && request.direction !== "after") {
|
|
40
|
+
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
|
|
43
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const insertToolSchema = Type.Object(
|
|
48
|
+
{
|
|
49
|
+
path: Type.String({
|
|
50
|
+
description:
|
|
51
|
+
"Path to the file to edit",
|
|
52
|
+
}),
|
|
53
|
+
anchor: Type.String({
|
|
54
|
+
description:
|
|
55
|
+
'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. A pasted diff row like `+aB3│x` or a `HASH│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
|
|
56
|
+
}),
|
|
57
|
+
direction: Type.Union(
|
|
58
|
+
[
|
|
59
|
+
Type.Literal("after", { description: "Insert the lines after the anchor line" }),
|
|
60
|
+
Type.Literal("before", { description: "Insert the lines before the anchor line" }),
|
|
61
|
+
],
|
|
62
|
+
{ description: '"after" or "before"' },
|
|
63
|
+
),
|
|
64
|
+
lines: Type.Array(
|
|
65
|
+
Type.String({
|
|
66
|
+
description:
|
|
67
|
+
"One line to insert. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
|
|
68
|
+
}),
|
|
69
|
+
{
|
|
70
|
+
description:
|
|
71
|
+
'Lines to insert as an array of strings, one element per line. Use [""] for a blank line. The anchor line is preserved; never include it in lines.',
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
{ additionalProperties: false },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
|
|
79
|
+
const trimmedAnchor = raw.trim();
|
|
80
|
+
const warnings: string[] = [];
|
|
81
|
+
const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
|
|
82
|
+
return { ref: parseHashRef(anchorText), warnings };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildInsertEdit(
|
|
86
|
+
req: InsertReq,
|
|
87
|
+
preload: NormFile,
|
|
88
|
+
ref: Anchor,
|
|
89
|
+
): { editParams: ReqParams; anchorLine: string | undefined } {
|
|
90
|
+
const fileLines = splitLines(preload.normalized);
|
|
91
|
+
const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, req.path);
|
|
92
|
+
const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
|
|
93
|
+
const editParams: ReqParams = {
|
|
94
|
+
path: req.path,
|
|
95
|
+
remove_from: ref.hash,
|
|
96
|
+
remove_to: ref.hash,
|
|
97
|
+
replacement_lines:
|
|
98
|
+
anchorLine === undefined
|
|
99
|
+
? [...req.lines]
|
|
100
|
+
: req.direction === "after"
|
|
101
|
+
? [anchorLine, ...req.lines]
|
|
102
|
+
: [...req.lines, anchorLine],
|
|
103
|
+
};
|
|
104
|
+
return { editParams, anchorLine };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function insertPreview(request: unknown, cwd: string): Promise<RPreview> {
|
|
108
|
+
try {
|
|
109
|
+
const normalized = normReq(request);
|
|
110
|
+
assertInsertReq(normalized);
|
|
111
|
+
const { ref } = parseInsertAnchor(normalized.anchor);
|
|
112
|
+
const preload = await readNormFile(normalized.path, cwd, {
|
|
113
|
+
accessMode: constants.R_OK,
|
|
114
|
+
maxLines: MAX_HASH_LINES,
|
|
115
|
+
noPersist: true,
|
|
116
|
+
});
|
|
117
|
+
const { editParams } = buildInsertEdit(normalized, preload, ref);
|
|
118
|
+
const pipe = await execPipeline(editParams, cwd, {
|
|
119
|
+
accessMode: constants.R_OK,
|
|
120
|
+
noPersist: true,
|
|
121
|
+
preloadedNorm: preload,
|
|
122
|
+
skipBoundaryDedup: true,
|
|
123
|
+
});
|
|
124
|
+
if (pipe.originalNormalized === pipe.result) {
|
|
125
|
+
return { error: `No changes made to ${normalized.path}. The edit produced identical content.` };
|
|
126
|
+
}
|
|
127
|
+
return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
|
|
128
|
+
} catch (error: unknown) {
|
|
129
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getInsertInput(args: unknown): { path?: string; anchor?: string; direction?: "before" | "after"; lines?: string[] } | null {
|
|
134
|
+
let normalized: unknown;
|
|
135
|
+
try {
|
|
136
|
+
normalized = normReq(args);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!isRec(normalized) || typeof normalized.path !== "string") return null;
|
|
141
|
+
if (
|
|
142
|
+
typeof normalized.anchor !== "string" ||
|
|
143
|
+
(normalized.direction !== "before" && normalized.direction !== "after") ||
|
|
144
|
+
!Array.isArray(normalized.lines) ||
|
|
145
|
+
normalized.lines.some((line) => typeof line !== "string")
|
|
146
|
+
) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
path: normalized.path,
|
|
151
|
+
anchor: normalized.anchor,
|
|
152
|
+
direction: normalized.direction,
|
|
153
|
+
lines: normalized.lines,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type InsertToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
|
|
158
|
+
|
|
159
|
+
export function buildInsertToolDef(): InsertToolDef {
|
|
160
|
+
return {
|
|
161
|
+
name: "insert",
|
|
162
|
+
label: "Insert",
|
|
163
|
+
description: loadP("../prompts/insert.md"),
|
|
164
|
+
promptSnippet: loadP("../prompts/insert-snippet.md"),
|
|
165
|
+
promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
166
|
+
prepareArguments: makePrepareArguments(),
|
|
167
|
+
parameters: insertToolSchema,
|
|
168
|
+
renderShell: "default",
|
|
169
|
+
renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
|
|
170
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
171
|
+
return renderEditResult(
|
|
172
|
+
result as {
|
|
173
|
+
content?: Array<{ type: string; text?: string }>;
|
|
174
|
+
details?: ReplaceDetails;
|
|
175
|
+
},
|
|
176
|
+
isPartial,
|
|
177
|
+
theme,
|
|
178
|
+
context,
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
183
|
+
const canonical = normReq(params);
|
|
184
|
+
assertInsertReq(canonical);
|
|
185
|
+
const req = canonical;
|
|
186
|
+
const path = req.path;
|
|
187
|
+
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
188
|
+
const absolutePath = toCwd(path, ctx.cwd);
|
|
189
|
+
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
190
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
191
|
+
abortIf(signal);
|
|
192
|
+
const preload = await readNormFile(path, ctx.cwd, {
|
|
193
|
+
signal,
|
|
194
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
195
|
+
maxLines: MAX_HASH_LINES,
|
|
196
|
+
});
|
|
197
|
+
const { editParams, anchorLine } = buildInsertEdit(req, preload, ref);
|
|
198
|
+
const pipe = await execPipeline(editParams, ctx.cwd, {
|
|
199
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
200
|
+
signal,
|
|
201
|
+
preloadedNorm: preload,
|
|
202
|
+
skipBoundaryDedup: true,
|
|
203
|
+
});
|
|
204
|
+
return commitEdit(pipe, {
|
|
205
|
+
path,
|
|
206
|
+
absolutePath,
|
|
207
|
+
mutationTargetPath,
|
|
208
|
+
signal,
|
|
209
|
+
verb: "inserted",
|
|
210
|
+
noopNoun: "Insertion",
|
|
211
|
+
foldedAnchorLines: anchorLine === undefined ? 0 : 1,
|
|
212
|
+
prefixWarnings: anchorWarnings,
|
|
213
|
+
onApplied: () => clearBoundaryBypass(mutationTargetPath),
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function regInsert(pi: ExtensionAPI): void {
|
|
221
|
+
pi.registerTool(buildInsertToolDef());
|
|
222
|
+
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -47,6 +47,10 @@ function fmtDiffLine(
|
|
|
47
47
|
return `${prefix}${hash}${HASH_SEP}${line}`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function isBlankLine(line: string): boolean {
|
|
51
|
+
return line.trim().length === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
50
54
|
const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis");
|
|
51
55
|
const isEllipsisMarker = (line: string | symbol): line is symbol =>
|
|
52
56
|
line === ELLIPSIS_MARKER;
|
|
@@ -99,15 +103,52 @@ export function genDiff(
|
|
|
99
103
|
let skipTail = 0;
|
|
100
104
|
|
|
101
105
|
if (!lastWasChange) {
|
|
102
|
-
|
|
106
|
+
let count = contextLines;
|
|
107
|
+
if (
|
|
108
|
+
contextLines > 0 &&
|
|
109
|
+
displayLines.length > count &&
|
|
110
|
+
isBlankLine(displayLines[displayLines.length - 1]!)
|
|
111
|
+
) {
|
|
112
|
+
count += 1;
|
|
113
|
+
}
|
|
114
|
+
count = Math.min(count, displayLines.length);
|
|
115
|
+
skipStart = displayLines.length - count;
|
|
103
116
|
linesToShow = displayLines.slice(skipStart);
|
|
104
117
|
} else if (nextPartIsChange && displayLines.length > contextLines * 2) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
let headCount = contextLines;
|
|
119
|
+
let tailCount = contextLines;
|
|
120
|
+
if (
|
|
121
|
+
contextLines > 0 &&
|
|
122
|
+
displayLines.length - headCount > tailCount &&
|
|
123
|
+
isBlankLine(displayLines[headCount - 1]!)
|
|
124
|
+
) {
|
|
125
|
+
headCount += 1;
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
contextLines > 0 &&
|
|
129
|
+
displayLines.length - tailCount > headCount &&
|
|
130
|
+
isBlankLine(displayLines[displayLines.length - tailCount]!)
|
|
131
|
+
) {
|
|
132
|
+
tailCount += 1;
|
|
133
|
+
}
|
|
134
|
+
const middleLen = displayLines.length - headCount - tailCount;
|
|
135
|
+
if (middleLen > 0) {
|
|
136
|
+
linesToShow = [
|
|
137
|
+
...displayLines.slice(0, headCount),
|
|
138
|
+
ELLIPSIS_MARKER,
|
|
139
|
+
...displayLines.slice(displayLines.length - tailCount),
|
|
140
|
+
];
|
|
141
|
+
skipMiddle = middleLen;
|
|
142
|
+
} else {
|
|
143
|
+
linesToShow = displayLines;
|
|
144
|
+
}
|
|
108
145
|
} else if (!nextPartIsChange && linesToShow.length > contextLines) {
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
let count = contextLines;
|
|
147
|
+
const firstLine = linesToShow[0];
|
|
148
|
+
if (contextLines > 0 && typeof firstLine === "string" && isBlankLine(firstLine)) count += 1;
|
|
149
|
+
count = Math.min(count, linesToShow.length);
|
|
150
|
+
linesToShow = linesToShow.slice(0, count);
|
|
151
|
+
skipTail = displayLines.length - count;
|
|
111
152
|
}
|
|
112
153
|
|
|
113
154
|
if (skipStart > 0) {
|