pi-hashline-edit-pro 6.3.1 → 6.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -32
- package/index.ts +32 -16
- package/package.json +1 -1
- package/src/batch.ts +30 -9
- package/src/commit.ts +17 -3
- package/src/config-ui.ts +1 -1
- package/src/constants.ts +3 -0
- package/src/copy-move.ts +100 -68
- package/src/edit-common.ts +86 -54
- package/src/edit-fidelity.ts +16 -6
- package/src/fs-write.ts +14 -2
- package/src/grep.ts +22 -11
- package/src/insert.ts +5 -4
- package/src/line-endings.ts +6 -2
- package/src/model-gate.ts +1 -1
- package/src/payload-contract.ts +13 -9
- package/src/pre-edit-snapshot.ts +19 -0
- package/src/prompts.ts +32 -0
- package/src/read.ts +5 -10
- package/src/replace-diff.ts +5 -3
- package/src/replace-match.ts +2 -2
- package/src/replace-render.ts +4 -4
- package/src/replace-response.ts +1 -0
- package/src/replace-undo.ts +4 -4
- package/src/replace.ts +2 -2
- package/src/structured.ts +2 -1
- package/src/utils.ts +29 -0
- package/src/write-hook.ts +1 -1
- package/tool-prompts/copy-snippet.md +1 -1
- package/tool-prompts/copy.md +2 -2
- package/tool-prompts/grep-guidelines.md +2 -1
- package/tool-prompts/grep-snippet.md +1 -1
- package/tool-prompts/grep.md +1 -1
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move-snippet.md +1 -1
- package/tool-prompts/move.md +2 -2
- package/tool-prompts/read-guidelines.md +2 -2
- package/tool-prompts/read.md +1 -3
- package/tool-prompts/replace-match-guidelines.md +1 -0
- package/tool-prompts/replace-match-snippet.md +1 -1
- package/tool-prompts/replace-match.md +1 -1
- package/tool-prompts/replace.md +3 -2
- package/tool-prompts/transfer-guidelines.md +3 -3
- package/tool-prompts/undo-last-change-snippet.md +1 -1
- package/tool-prompts/undo-last-change.md +1 -1
package/src/edit-fidelity.ts
CHANGED
|
@@ -218,15 +218,22 @@ function sameCallHead(payloadLine: string, referenceLine: string): boolean {
|
|
|
218
218
|
return left !== undefined && left === right;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
function indentMismatchHint(
|
|
221
|
+
function indentMismatchHint(
|
|
222
|
+
payloadLine: string,
|
|
223
|
+
reference: ReferenceRow,
|
|
224
|
+
newAnchor: string | undefined,
|
|
225
|
+
referenceAnchor: string | undefined,
|
|
226
|
+
): string | undefined {
|
|
222
227
|
const payloadIndent = leadingWhitespace(payloadLine);
|
|
223
228
|
const referenceIndent = leadingWhitespace(reference.line);
|
|
224
229
|
if (payloadIndent.length >= referenceIndent.length || !referenceIndent.startsWith(payloadIndent)) return undefined;
|
|
225
230
|
const grams = gramSet([payloadLine], INDENT_SIMILARITY_RUN);
|
|
226
231
|
const sharesLongRun = grams !== undefined && sharesRun(reference.line, grams, INDENT_SIMILARITY_RUN);
|
|
227
232
|
if (!sharesLongRun && !sameCallHead(payloadLine, reference.line)) return undefined;
|
|
228
|
-
const
|
|
229
|
-
|
|
233
|
+
const count = plural(payloadIndent.length, "leading whitespace character");
|
|
234
|
+
const referenceLabel = referenceAnchor === undefined ? "the nearby line" : `neighboring line ${referenceAnchor}${HASH_SEP}`;
|
|
235
|
+
const newLabel = newAnchor === undefined ? "new line" : `new line ${newAnchor}${HASH_SEP}`;
|
|
236
|
+
return `[H_INDENT_MISMATCH] ${newLabel} has ${count}; ${referenceLabel} has ${referenceIndent.length}.`;
|
|
230
237
|
}
|
|
231
238
|
|
|
232
239
|
function referenceRows(lines: string[], start: number, end: number): ReferenceRow[] {
|
|
@@ -373,11 +380,12 @@ export function fidelityHints(
|
|
|
373
380
|
resultContent: string,
|
|
374
381
|
spans: readonly DiffSpan[] | undefined,
|
|
375
382
|
originalHashes?: readonly string[],
|
|
376
|
-
options?: { separatorMoved?: boolean; indentHints?: boolean },
|
|
383
|
+
options?: { separatorMoved?: boolean; indentHints?: boolean; resultHashes?: readonly string[] },
|
|
377
384
|
): string[] {
|
|
378
385
|
if (spans === undefined || spans.length === 0) return [];
|
|
379
386
|
const oldLines = splitLines(originalContent);
|
|
380
387
|
const newLines = splitLines(resultContent);
|
|
388
|
+
const resultHashes = options?.resultHashes;
|
|
381
389
|
const hints: string[] = [];
|
|
382
390
|
const seen = new Set<string>();
|
|
383
391
|
let sensitiveIndex: Map<string, IndexedLine[]> | undefined;
|
|
@@ -460,7 +468,7 @@ export function fidelityHints(
|
|
|
460
468
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
461
469
|
for (const candidate of references) {
|
|
462
470
|
if (candidate.index < span.start - INDENT_REFERENCE_WINDOW || candidate.index > span.end + INDENT_REFERENCE_WINDOW) continue;
|
|
463
|
-
if (indentMismatchHint(payload[payloadIndex]!, candidate, undefined) === undefined) continue;
|
|
471
|
+
if (indentMismatchHint(payload[payloadIndex]!, candidate, undefined, undefined) === undefined) continue;
|
|
464
472
|
const distance = Math.abs(candidate.index - span.start);
|
|
465
473
|
if (distance < nearestDistance) {
|
|
466
474
|
nearest = candidate;
|
|
@@ -474,12 +482,14 @@ export function fidelityHints(
|
|
|
474
482
|
}
|
|
475
483
|
return undefined;
|
|
476
484
|
};
|
|
485
|
+
const resultIndexForPayload = (payloadIndex: number): number =>
|
|
486
|
+
span.start + offset + (carried !== undefined && payloadIndex >= carried ? payloadIndex + 1 : payloadIndex);
|
|
477
487
|
for (let index = 0; index < payload.length; index += 1) {
|
|
478
488
|
if (hints.length >= MAX_FIDELITY_HINTS) break;
|
|
479
489
|
const payloadLine = payload[index]!;
|
|
480
490
|
const reference = indentReferenceFor(index);
|
|
481
491
|
if (reference === undefined) continue;
|
|
482
|
-
const indentHint = indentMismatchHint(payloadLine, reference, originalHashes?.[reference.index]);
|
|
492
|
+
const indentHint = indentMismatchHint(payloadLine, reference, resultHashes?.[resultIndexForPayload(index)], originalHashes?.[reference.index]);
|
|
483
493
|
if (indentHint === undefined) continue;
|
|
484
494
|
const key = `indent:${reference.index}:${payloadLine}`;
|
|
485
495
|
if (seen.has(key)) continue;
|
package/src/fs-write.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "node:fs/promises";
|
|
14
14
|
import { dirname, join, parse, resolve, sep } from "node:path";
|
|
15
15
|
import { toCwd } from "./paths";
|
|
16
|
-
import { errCode, isModeUnsupported } from "./utils";
|
|
16
|
+
import { errCode, isModeUnsupported, mapIoError } from "./utils";
|
|
17
17
|
|
|
18
18
|
export interface FileIdentity {
|
|
19
19
|
dev: number;
|
|
@@ -138,7 +138,7 @@ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolut
|
|
|
138
138
|
const resolved = await resolveTarget(absolute);
|
|
139
139
|
return { absolute, resolved };
|
|
140
140
|
}
|
|
141
|
-
|
|
141
|
+
async function writeAtomicRaw(
|
|
142
142
|
path: string,
|
|
143
143
|
content: string,
|
|
144
144
|
expectedIdentity?: FileIdentity,
|
|
@@ -223,3 +223,15 @@ export async function writeAtomic(
|
|
|
223
223
|
throw error;
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
export async function writeAtomic(
|
|
227
|
+
path: string,
|
|
228
|
+
content: string,
|
|
229
|
+
expectedIdentity?: FileIdentity,
|
|
230
|
+
restoreMode?: number,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
try {
|
|
233
|
+
await writeAtomicRaw(path, content, expectedIdentity, restoreMode);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw mapIoError(error, "write", path);
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/grep.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createGrepToolDefinition, type TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { stat } from "node:fs/promises";
|
|
5
5
|
import { dirname, isAbsolute, join, win32 } from "node:path";
|
|
@@ -11,10 +11,10 @@ import { globToRegex } from "./glob";
|
|
|
11
11
|
import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP, HASH_CLASS } from "./hashline";
|
|
12
12
|
import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
|
|
13
13
|
import { toCwd, toDisplayPath } from "./paths";
|
|
14
|
-
import { loadP,
|
|
14
|
+
import { loadP, loadGuidelines } from "./prompts";
|
|
15
15
|
import { normReq } from "./payload-contract";
|
|
16
16
|
import { DEFAULT_EDIT_FLAGS, withGrepPrompts, type EditToolFlags } from "./edit-common";
|
|
17
|
-
import { abortIf, clipLine, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines,
|
|
17
|
+
import { abortIf, clipLine, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines, gutterWidth } from "./utils";
|
|
18
18
|
import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
19
19
|
import { serveRows } from "./served";
|
|
20
20
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -351,7 +351,12 @@ async function collectRgMatches(
|
|
|
351
351
|
|
|
352
352
|
|
|
353
353
|
function displayRowsForHit(hit: FileHit): string[] {
|
|
354
|
-
|
|
354
|
+
const matched = new Set(hit.matchLines);
|
|
355
|
+
const width = gutterWidth(hit.lineNumbers.reduce((m, n) => (n > m ? n : m), 0), hit.lineNumbers.length);
|
|
356
|
+
return hit.rows.map((row, index) => {
|
|
357
|
+
const lineNumber = hit.lineNumbers[index]!;
|
|
358
|
+
return `${String(lineNumber).padStart(width)}${matched.has(lineNumber) ? ":" : "-"}${HASH_SEP} ${row}`;
|
|
359
|
+
});
|
|
355
360
|
}
|
|
356
361
|
|
|
357
362
|
const grepToolSchema = Type.Object(
|
|
@@ -408,7 +413,7 @@ export function fmtGrepCall(args: { pattern?: unknown; path?: unknown; glob?: un
|
|
|
408
413
|
if (args?.ignoreCase === true) qualifiers.push("case-insensitive");
|
|
409
414
|
if (typeof args?.context === "number") qualifiers.push(`context ${args.context}`);
|
|
410
415
|
if (typeof args?.limit === "number") qualifiers.push(`limit ${args.limit}`);
|
|
411
|
-
let text = `${theme.fg("toolTitle", theme.bold("
|
|
416
|
+
let text = `${theme.fg("toolTitle", theme.bold("grep"))} ${pattern}`;
|
|
412
417
|
if (qualifiers.length > 0) text += ` ${theme.fg("dim", qualifiers.join(" "))}`;
|
|
413
418
|
return text;
|
|
414
419
|
}
|
|
@@ -436,6 +441,7 @@ function highlightMatches(text: string, regex: RegExp, theme: FgT): string {
|
|
|
436
441
|
}
|
|
437
442
|
|
|
438
443
|
const ANCHORED_ROW_RE = new RegExp(`^${HASH_CLASS}${HASH_SEP}`);
|
|
444
|
+
const GREP_GUTTER_RE = /^\s*\d+(?: │ |[:-]│ )/;
|
|
439
445
|
|
|
440
446
|
function highlightHitRow(row: string, highlight: RegExp, theme: FgT): string {
|
|
441
447
|
const anchored = row.match(ANCHORED_ROW_RE);
|
|
@@ -447,9 +453,9 @@ function styleGrepLine(line: string, highlight: RegExp | undefined, theme: FgT):
|
|
|
447
453
|
if (line.startsWith("=== ")) return theme.fg("accent", line);
|
|
448
454
|
if (line.startsWith("[grep:") || line.startsWith("... ")) return theme.fg("dim", line);
|
|
449
455
|
if (!highlight) return line;
|
|
450
|
-
const gutter =
|
|
451
|
-
if (gutter
|
|
452
|
-
const cut = gutter
|
|
456
|
+
const gutter = GREP_GUTTER_RE.exec(line);
|
|
457
|
+
if (gutter === null) return highlightHitRow(line, highlight, theme);
|
|
458
|
+
const cut = gutter[0].length;
|
|
453
459
|
return line.slice(0, cut) + highlightHitRow(line.slice(cut), highlight, theme);
|
|
454
460
|
}
|
|
455
461
|
|
|
@@ -471,11 +477,11 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
471
477
|
const prompted = withGrepPrompts({
|
|
472
478
|
description: loadP("../tool-prompts/grep.md"),
|
|
473
479
|
snippet: loadP("../tool-prompts/grep-snippet.md"),
|
|
474
|
-
guidelines:
|
|
480
|
+
guidelines: loadGuidelines("../tool-prompts/grep-guidelines.md"),
|
|
475
481
|
}, flags);
|
|
476
482
|
pi.registerTool({
|
|
477
|
-
name: "
|
|
478
|
-
label: "
|
|
483
|
+
name: "grep",
|
|
484
|
+
label: "Grep",
|
|
479
485
|
description: prompted.description,
|
|
480
486
|
promptSnippet: prompted.snippet,
|
|
481
487
|
promptGuidelines: prompted.guidelines,
|
|
@@ -627,6 +633,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
627
633
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
628
634
|
if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
|
|
629
635
|
if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
|
|
636
|
+
if (context > 0 && hits.length > 0) notes.push('[grep: match rows are "line:│", context rows are "line-│".]');
|
|
630
637
|
const truncated = limitTruncated || rowTruncated;
|
|
631
638
|
const truncation: TruncationResult | undefined = rowTruncated
|
|
632
639
|
? {
|
|
@@ -679,3 +686,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
679
686
|
},
|
|
680
687
|
});
|
|
681
688
|
}
|
|
689
|
+
|
|
690
|
+
export function regVanillaGrep(pi: ExtensionAPI, cwd: string): void {
|
|
691
|
+
pi.registerTool(createGrepToolDefinition(cwd));
|
|
692
|
+
}
|
package/src/insert.ts
CHANGED
|
@@ -6,10 +6,11 @@ import { commitEdit } from "./commit";
|
|
|
6
6
|
import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
|
|
7
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
8
|
import { MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, type Anchor, type HTEdit, type StripWarningLocation } from "./hashline";
|
|
9
|
+
import { TEXT_ENDING_NOTE } from "./constants";
|
|
9
10
|
import type { LineEnding } from "./normalize";
|
|
10
11
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
12
|
import { withAnchorSession } from "./anchor-registry";
|
|
12
|
-
import { loadP,
|
|
13
|
+
import { loadP, loadGuidelines } from "./prompts";
|
|
13
14
|
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
14
15
|
import { isRec, literalEscapeHints, splitLines } from "./utils";
|
|
15
16
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
@@ -19,7 +20,7 @@ export { assertInsertReq, type InsertReq };
|
|
|
19
20
|
|
|
20
21
|
const insertAnchorSchema = Type.String({
|
|
21
22
|
description:
|
|
22
|
-
"4-char anchor of the line to insert next to
|
|
23
|
+
"4-char anchor of the line to insert next to.",
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
const insertDirectionSchema = Type.Union(
|
|
@@ -28,7 +29,7 @@ const insertDirectionSchema = Type.Union(
|
|
|
28
29
|
);
|
|
29
30
|
const insertTextSchema = Type.String({
|
|
30
31
|
description:
|
|
31
|
-
|
|
32
|
+
`The exact text to insert; an empty string inserts one blank line. ${TEXT_ENDING_NOTE} Never include the anchor line.`,
|
|
32
33
|
});
|
|
33
34
|
|
|
34
35
|
const insertPathRequiredSchema = Type.String({
|
|
@@ -192,7 +193,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
192
193
|
const prompted = withInsertPrompts({
|
|
193
194
|
description: loadP("../tool-prompts/insert.md"),
|
|
194
195
|
snippet: loadP("../tool-prompts/insert-snippet.md"),
|
|
195
|
-
guidelines:
|
|
196
|
+
guidelines: loadGuidelines("../tool-prompts/insert-guidelines.md"),
|
|
196
197
|
}, flags);
|
|
197
198
|
return {
|
|
198
199
|
name: "insert",
|
package/src/line-endings.ts
CHANGED
|
@@ -35,9 +35,13 @@ export function splitSeparators(raw: string): LineEnding[] {
|
|
|
35
35
|
export function joinSeparators(content: string, separators: readonly LineEnding[]): string {
|
|
36
36
|
let out = "";
|
|
37
37
|
let separatorIndex = 0;
|
|
38
|
-
for (
|
|
38
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
39
|
+
const char = content[index]!;
|
|
39
40
|
if (char === "\n") {
|
|
40
|
-
|
|
41
|
+
const separator = separators[separatorIndex] ?? "\n";
|
|
42
|
+
const emptyLineBefore = content[index - 1] === "\n";
|
|
43
|
+
const followsLoneCr = separator === "\n" && separators[separatorIndex - 1] === "\r" && emptyLineBefore;
|
|
44
|
+
out += followsLoneCr ? "\r\n" : separator;
|
|
41
45
|
separatorIndex += 1;
|
|
42
46
|
} else {
|
|
43
47
|
out += char;
|
package/src/model-gate.ts
CHANGED
package/src/payload-contract.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./utils";
|
|
3
|
-
import { TEXT_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
|
|
3
|
+
import { TEXT_ENDING_NOTE, TEXT_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
|
|
4
4
|
|
|
5
5
|
const textSchema = Type.String({
|
|
6
6
|
description:
|
|
7
|
-
|
|
7
|
+
`The exact text to write in place of the removed range. "" deletes the range. ${TEXT_ENDING_NOTE}`,
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
const removeFromSchema = Type.String({
|
|
11
11
|
description:
|
|
12
|
-
"4-char anchor of the FIRST line to remove
|
|
12
|
+
"4-char anchor of the FIRST line to remove.",
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
const removeToSchema = Type.String({
|
|
@@ -134,13 +134,14 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
134
134
|
assertNoNul([request.text]);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
const TRANSFER_KEYS = new Set(["path", "source_from", "source_to", "
|
|
137
|
+
const TRANSFER_KEYS = new Set(["path", "source_from", "source_to", "anchor", "direction"]);
|
|
138
138
|
|
|
139
139
|
export interface TransferReq {
|
|
140
140
|
path?: string;
|
|
141
141
|
source_from: string;
|
|
142
142
|
source_to: string;
|
|
143
|
-
|
|
143
|
+
anchor: string;
|
|
144
|
+
direction: "before" | "after";
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
export function assertTransferReq(request: unknown): asserts request is TransferReq {
|
|
@@ -157,8 +158,11 @@ export function assertTransferReq(request: unknown): asserts request is Transfer
|
|
|
157
158
|
if (typeof request.source_to !== "string" || request.source_to.length === 0) {
|
|
158
159
|
throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_to" string (4-char anchor from read output).');
|
|
159
160
|
}
|
|
160
|
-
if (typeof request.
|
|
161
|
-
throw new Error('[E_BAD_SHAPE] Copy/move request requires an "
|
|
161
|
+
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
162
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request requires an "anchor" string (4-char anchor from read output).');
|
|
163
|
+
}
|
|
164
|
+
if (request.direction !== "before" && request.direction !== "after") {
|
|
165
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request "direction" must be "before" or "after" (the anchor is the destination line).');
|
|
162
166
|
}
|
|
163
167
|
}
|
|
164
168
|
|
|
@@ -222,7 +226,7 @@ export function getReplaceMatchInput(args: unknown): { path?: string; replace_fr
|
|
|
222
226
|
|
|
223
227
|
const replaceMatchFromSchema = Type.String({
|
|
224
228
|
description:
|
|
225
|
-
"4-char anchor of the FIRST line of the range searched
|
|
229
|
+
"4-char anchor of the FIRST line of the range searched.",
|
|
226
230
|
});
|
|
227
231
|
const replaceMatchToSchema = Type.String({
|
|
228
232
|
description:
|
|
@@ -234,7 +238,7 @@ const replaceMatchOldStringSchema = Type.String({
|
|
|
234
238
|
});
|
|
235
239
|
const replaceMatchNewStringSchema = Type.String({
|
|
236
240
|
description:
|
|
237
|
-
|
|
241
|
+
`The exact replacement for every matched occurrence. "" deletes the matches. ${TEXT_ENDING_NOTE} The rest of the range is kept byte-for-byte.`,
|
|
238
242
|
});
|
|
239
243
|
const replaceMatchPathRequiredSchema = Type.String({
|
|
240
244
|
description:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { NormFile } from "./file-reader";
|
|
2
|
+
|
|
3
|
+
export interface PreEditSnapshot extends NormFile {
|
|
4
|
+
served?: ReadonlyMap<string, string>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const snapshots = new Map<string, PreEditSnapshot>();
|
|
8
|
+
|
|
9
|
+
export function recordPreEditSnapshot(snapshot: PreEditSnapshot): void {
|
|
10
|
+
snapshots.set(snapshot.absolutePath, snapshot);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function preEditSnapshotFor(path: string): PreEditSnapshot | undefined {
|
|
14
|
+
return snapshots.get(path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clearPreEditSnapshots(): void {
|
|
18
|
+
snapshots.clear();
|
|
19
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { errCode } from "./utils";
|
|
3
3
|
|
|
4
|
+
export interface Guideline {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const GUIDELINE_IDS_BY_NAME: Record<string, readonly string[]> = {
|
|
10
|
+
"read-guidelines.md": ["read-reread", "read-outline", "read-match-served"],
|
|
11
|
+
"replace-guidelines.md": ["replace-disjoint", "replace-deletion", "replace-single-line"],
|
|
12
|
+
"replace-match-guidelines.md": ["replace-match-fragment", "replace-match-wide-range"],
|
|
13
|
+
"insert-guidelines.md": ["insert-blank-edges"],
|
|
14
|
+
"copy-guidelines.md": ["copy-single-anchor"],
|
|
15
|
+
"move-guidelines.md": ["move-same-place"],
|
|
16
|
+
"transfer-guidelines.md": ["transfer-boundary-serving", "transfer-same-file", "transfer-cross-file", "transfer-batch-move"],
|
|
17
|
+
"grep-guidelines.md": ["grep-literal", "grep-row-markers"],
|
|
18
|
+
"undo-last-change-guidelines.md": ["undo-single-level", "undo-write-clears", "undo-cross-file-move"],
|
|
19
|
+
};
|
|
20
|
+
|
|
4
21
|
export function loadP(relativePath: string): string {
|
|
5
22
|
return readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
|
|
6
23
|
}
|
|
@@ -22,3 +39,18 @@ export function loadGuide(relativePath: string): string[] {
|
|
|
22
39
|
.filter((line) => line.startsWith("- "))
|
|
23
40
|
.map((line) => line.slice(2));
|
|
24
41
|
}
|
|
42
|
+
|
|
43
|
+
export function loadGuidelines(relativePath: string): Guideline[] {
|
|
44
|
+
const name = relativePath.slice(relativePath.lastIndexOf("/") + 1);
|
|
45
|
+
const ids = GUIDELINE_IDS_BY_NAME[name];
|
|
46
|
+
if (ids === undefined) throw new Error(`[E_REGISTRY] No guideline ids are registered for ${relativePath}.`);
|
|
47
|
+
const texts = loadGuide(relativePath);
|
|
48
|
+
if (texts.length === 0) return [];
|
|
49
|
+
if (texts.length !== ids.length) {
|
|
50
|
+
console.warn(`hashline: ${relativePath} holds ${texts.length} guideline(s) but declares ${ids.length}; gating may be wrong`);
|
|
51
|
+
}
|
|
52
|
+
return ids.flatMap((id, index) => {
|
|
53
|
+
const text = texts[index];
|
|
54
|
+
return text === undefined ? [] : [{ id, text }];
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { readNormFile, safeSnapId } from "./file-reader";
|
|
|
16
16
|
import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES, parseHashRef, resolveAnchorLine, AnchorMismatchError } from "./hashline";
|
|
17
17
|
import { toCwd } from "./paths";
|
|
18
18
|
import { abortIf, isRec, makePrepareArguments, numberedRead, visLines, splitLines } from "./utils";
|
|
19
|
-
import { loadP,
|
|
19
|
+
import { loadP, loadGuidelines, type Guideline } from "./prompts";
|
|
20
20
|
import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
21
21
|
import { valAccess } from "./validation";
|
|
22
22
|
import { withAnchorSession, adoptAnchors, ownerOf, ownersDifferingOnlyByCase, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
@@ -26,8 +26,8 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
26
26
|
import { anchoredLine, readResultSchema, withStructuredErrors, type AnchoredLine, type ReadResult } from "./structured";
|
|
27
27
|
const R_DESC = loadP("../tool-prompts/read.md");
|
|
28
28
|
const R_SNIPPET = loadP("../tool-prompts/read-snippet.md");
|
|
29
|
-
function readGuide():
|
|
30
|
-
return
|
|
29
|
+
function readGuide(): Guideline[] {
|
|
30
|
+
return loadGuidelines("../tool-prompts/read-guidelines.md");
|
|
31
31
|
}
|
|
32
32
|
function normPosInt(
|
|
33
33
|
value: number | undefined,
|
|
@@ -300,13 +300,8 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
300
300
|
}),
|
|
301
301
|
offset: Type.Optional(
|
|
302
302
|
Type.Union([
|
|
303
|
-
Type.Integer({
|
|
304
|
-
|
|
305
|
-
description: "Line number to start reading from (1-indexed)",
|
|
306
|
-
}),
|
|
307
|
-
Type.String({
|
|
308
|
-
description: "4-char anchor of a served line to start reading from",
|
|
309
|
-
}),
|
|
303
|
+
Type.Integer({ minimum: 1 }),
|
|
304
|
+
Type.String(),
|
|
310
305
|
], { description: "Line number (1-indexed) or a served anchor to start reading from" }),
|
|
311
306
|
),
|
|
312
307
|
limit: Type.Optional(
|
package/src/replace-diff.ts
CHANGED
|
@@ -163,6 +163,8 @@ function trimPlacedSpans(
|
|
|
163
163
|
placed: PlacedSpan[],
|
|
164
164
|
oldLines: string[],
|
|
165
165
|
newLines: string[],
|
|
166
|
+
oldHashes: string[],
|
|
167
|
+
newHashes: string[],
|
|
166
168
|
): PlacedSpan[] {
|
|
167
169
|
const out: PlacedSpan[] = [];
|
|
168
170
|
for (const span of placed) {
|
|
@@ -170,11 +172,11 @@ function trimPlacedSpans(
|
|
|
170
172
|
let oldEnd = span.oldEnd;
|
|
171
173
|
let newStart = span.newStart;
|
|
172
174
|
let newEnd = span.newEnd;
|
|
173
|
-
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart]) {
|
|
175
|
+
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart] && oldHashes[oldStart] === newHashes[newStart]) {
|
|
174
176
|
oldStart += 1;
|
|
175
177
|
newStart += 1;
|
|
176
178
|
}
|
|
177
|
-
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd]) {
|
|
179
|
+
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd] && oldHashes[oldEnd] === newHashes[newEnd]) {
|
|
178
180
|
oldEnd -= 1;
|
|
179
181
|
newEnd -= 1;
|
|
180
182
|
}
|
|
@@ -251,7 +253,7 @@ function genSpanDiff(
|
|
|
251
253
|
if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
|
|
252
254
|
const placed = placeSpans(oldLines.length, newLines.length, spans);
|
|
253
255
|
if (!placed) return undefined;
|
|
254
|
-
const trimmed = trimPlacedSpans(placed, oldLines, newLines);
|
|
256
|
+
const trimmed = trimPlacedSpans(placed, oldLines, newLines, oldHashes, newHashes);
|
|
255
257
|
const ordered = [...trimmed].sort((a, b) => a.oldStart - b.oldStart || a.newStart - b.newStart);
|
|
256
258
|
let oldPos = 0;
|
|
257
259
|
let newPos = 0;
|
package/src/replace-match.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { readNormFile, type NormFile } from "./file-reader";
|
|
|
6
6
|
import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
|
|
7
7
|
import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
8
8
|
import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
|
|
9
|
-
import { loadP,
|
|
9
|
+
import { loadP, loadGuidelines } from "./prompts";
|
|
10
10
|
import {
|
|
11
11
|
assertReplaceMatchReq,
|
|
12
12
|
buildReplaceMatchToolSchema,
|
|
@@ -177,7 +177,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
|
|
|
177
177
|
const prompted = withReplaceMatchPrompts({
|
|
178
178
|
description: loadP("../tool-prompts/replace-match.md"),
|
|
179
179
|
snippet: loadP("../tool-prompts/replace-match-snippet.md"),
|
|
180
|
-
guidelines:
|
|
180
|
+
guidelines: loadGuidelines("../tool-prompts/replace-match-guidelines.md"),
|
|
181
181
|
}, flags);
|
|
182
182
|
return {
|
|
183
183
|
name: "replace_match",
|
package/src/replace-render.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function highlightBatchRefs(text: string, theme: FgT): string {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function fmtCall(
|
|
84
|
-
args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string;
|
|
84
|
+
args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; direction?: string; replace_from?: string; replace_to?: string } | undefined,
|
|
85
85
|
state: RRState,
|
|
86
86
|
expanded: boolean,
|
|
87
87
|
theme: CallT,
|
|
@@ -89,7 +89,7 @@ export function fmtCall(
|
|
|
89
89
|
): string {
|
|
90
90
|
const previewPath = state.preview && "path" in state.preview ? state.preview.path : undefined;
|
|
91
91
|
const path = args?.path ?? state.resolvedPath ?? previewPath;
|
|
92
|
-
const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.
|
|
92
|
+
const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.source_from === "string" && typeof args?.anchor === "string" ? `${args.source_from}→${args.anchor}` : typeof args?.anchor === "string" ? args.anchor : typeof args?.replace_from === "string" && typeof args?.replace_to === "string" ? `${args.replace_from}→${args.replace_to}` : undefined;
|
|
93
93
|
const pathDisplay =
|
|
94
94
|
typeof path === "string" && path.length > 0
|
|
95
95
|
? theme.fg("accent", path)
|
|
@@ -272,9 +272,9 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
272
272
|
export function makeRenderCall(
|
|
273
273
|
preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
|
|
274
274
|
options: {
|
|
275
|
-
getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string;
|
|
275
|
+
getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; direction?: string; replace_from?: string; replace_to?: string } | null;
|
|
276
276
|
toolName?: string;
|
|
277
|
-
resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string;
|
|
277
|
+
resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; direction?: string; replace_from?: string; replace_to?: string }) => string | undefined;
|
|
278
278
|
} = {},
|
|
279
279
|
) {
|
|
280
280
|
const getInput = options.getInput ?? getPreviewInput;
|
package/src/replace-response.ts
CHANGED
|
@@ -160,6 +160,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
160
160
|
const fidelity = fidelityHints(originalNormalized, result, spans, originalHashes, {
|
|
161
161
|
separatorMoved: fidelityOptions?.separatorMoved ?? (verb === "inserted" || verb === "edited"),
|
|
162
162
|
indentHints: fidelityOptions?.indentHints ?? (verb !== "copied" && verb !== "moved"),
|
|
163
|
+
resultHashes,
|
|
163
164
|
});
|
|
164
165
|
const { warnings: noticeWarnings, hints } = splitNotices(fidelity.length > 0 ? [...(warnings ?? []), ...fidelity] : warnings);
|
|
165
166
|
const annotatedHints = annotateLiteralEscapeHints(hints, result, spans, resultHashes);
|
package/src/replace-undo.ts
CHANGED
|
@@ -12,8 +12,8 @@ import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
|
|
|
12
12
|
import { joinSeparators } from "./line-endings";
|
|
13
13
|
import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
|
|
14
14
|
import { getDiffContextLines } from "./config";
|
|
15
|
-
import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
|
|
16
|
-
import { loadP,
|
|
15
|
+
import { cntDiff, errCode, makePrepareArguments, mapIoError, splitLines } from "./utils";
|
|
16
|
+
import { loadP, loadGuidelines } from "./prompts";
|
|
17
17
|
import { withUndoPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
18
18
|
import { buildMetrics } from "./replace-response";
|
|
19
19
|
import { renderEditResult, fmtCall } from "./replace-render";
|
|
@@ -115,7 +115,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
115
115
|
const prompted = withUndoPrompts({
|
|
116
116
|
description: loadP("../tool-prompts/undo-last-change.md"),
|
|
117
117
|
snippet: loadP("../tool-prompts/undo-last-change-snippet.md"),
|
|
118
|
-
guidelines:
|
|
118
|
+
guidelines: loadGuidelines("../tool-prompts/undo-last-change-guidelines.md"),
|
|
119
119
|
}, flags);
|
|
120
120
|
pi.registerTool({
|
|
121
121
|
name: "undo_last_change",
|
|
@@ -166,7 +166,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
166
166
|
await handle.close();
|
|
167
167
|
}
|
|
168
168
|
} catch (error) {
|
|
169
|
-
if (errCode(error) !== "ENOENT") throw error;
|
|
169
|
+
if (errCode(error) !== "ENOENT") throw mapIoError(error, "read", path);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
if (
|
package/src/replace.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { readNormFile, type NormFile } from "./file-reader";
|
|
|
12
12
|
import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
13
|
import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
|
|
14
14
|
import { editResultSchema, withStructuredErrors } from "./structured";
|
|
15
|
-
import { loadP,
|
|
15
|
+
import { loadP, loadGuidelines } from "./prompts";
|
|
16
16
|
import { type FileIdentity } from "./fs-write";
|
|
17
17
|
import { applyEdit,
|
|
18
18
|
lineHashes,
|
|
@@ -292,7 +292,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
292
292
|
const prompted = withReplacePrompts({
|
|
293
293
|
description: loadP("../tool-prompts/replace.md"),
|
|
294
294
|
snippet: loadP("../tool-prompts/replace-snippet.md"),
|
|
295
|
-
guidelines:
|
|
295
|
+
guidelines: loadGuidelines("../tool-prompts/replace-guidelines.md"),
|
|
296
296
|
}, flags);
|
|
297
297
|
const parameters = buildEditToolSchema(flags.requirePath);
|
|
298
298
|
return {
|
package/src/structured.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { HASH_CLASS, HASH_SEP } from "./hashline/hash";
|
|
3
|
+
import { ioErrorCode } from "./utils";
|
|
3
4
|
|
|
4
5
|
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
5
6
|
|
|
@@ -200,7 +201,7 @@ export function messageOf(error: unknown): string {
|
|
|
200
201
|
|
|
201
202
|
export function structuredError(error: unknown): StructuredError {
|
|
202
203
|
const message = messageOf(error);
|
|
203
|
-
return { ok: false, kind: "error", error: { code: ERROR_CODE_RE.exec(message)?.[1] ?? "E_TOOL", message } };
|
|
204
|
+
return { ok: false, kind: "error", error: { code: ERROR_CODE_RE.exec(message)?.[1] ?? ioErrorCode(error) ?? "E_TOOL", message } };
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
export type StructuredFailureResult<TDetails> = {
|
package/src/utils.ts
CHANGED
|
@@ -94,6 +94,35 @@ export function errCode(error: unknown): string | undefined {
|
|
|
94
94
|
return undefined;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
const IO_ERROR_CODES: Record<string, string> = {
|
|
98
|
+
ENOENT: "E_NOT_FOUND",
|
|
99
|
+
EACCES: "E_ACCESS",
|
|
100
|
+
EPERM: "E_ACCESS",
|
|
101
|
+
EROFS: "E_ACCESS",
|
|
102
|
+
EISDIR: "E_ACCESS",
|
|
103
|
+
ENOTDIR: "E_ACCESS",
|
|
104
|
+
EEXIST: "E_ACCESS",
|
|
105
|
+
ELOOP: "E_ACCESS",
|
|
106
|
+
ENOSPC: "E_ACCESS",
|
|
107
|
+
EDQUOT: "E_ACCESS",
|
|
108
|
+
EMFILE: "E_ACCESS",
|
|
109
|
+
ENFILE: "E_ACCESS",
|
|
110
|
+
ENAMETOOLONG: "E_ACCESS",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function ioErrorCode(error: unknown): string | undefined {
|
|
114
|
+
const code = errCode(error);
|
|
115
|
+
return code !== undefined ? IO_ERROR_CODES[code] : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function mapIoError(error: unknown, action: string, path: string): Error {
|
|
119
|
+
if (error instanceof Error && /\[[EWH]_[A-Z0-9_]+\]/.test(error.message)) return error;
|
|
120
|
+
const mapped = ioErrorCode(error);
|
|
121
|
+
if (mapped === undefined) return error instanceof Error ? error : new Error(String(error));
|
|
122
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
123
|
+
return new Error(`[${mapped}] Cannot ${action} ${path}: ${detail}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
97
126
|
export function isModeUnsupported(error: unknown): boolean {
|
|
98
127
|
const code = errCode(error);
|
|
99
128
|
return code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EINVAL" || code === "ENOSYS";
|
package/src/write-hook.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { resolveInCwd } from "./fs-write";
|
|
|
6
6
|
import { abortIf, splitLines, isRec } from "./utils";
|
|
7
7
|
import type { ModelLike } from "./model-gate";
|
|
8
8
|
|
|
9
|
-
const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+
|
|
9
|
+
const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+(?: │ |[:-]│ ))?[+ -]?(${HASH_CLASS})${HASH_SEP}`);
|
|
10
10
|
|
|
11
11
|
function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
12
12
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Copy lines to another position by anchor: `source_from`/`source_to` mark the range,
|
|
1
|
+
Copy lines to another position by anchor: `source_from`/`source_to` mark the range, `anchor` and `direction` the destination
|