@d3ara1n/pi-hashline-edit 0.1.0 → 0.1.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 +12 -15
- package/package.json +1 -1
- package/src/core/apply.test.ts +14 -14
- package/src/core/apply.ts +21 -18
- package/src/core/diff.test.ts +4 -4
- package/src/core/diff.ts +11 -9
- package/src/core/hash.test.ts +8 -8
- package/src/core/hash.ts +25 -20
- package/src/core/index.ts +3 -3
- package/src/core/parse.test.ts +19 -19
- package/src/core/parse.ts +10 -9
- package/src/core/snapshot.test.ts +8 -8
- package/src/core/snapshot.ts +25 -19
- package/src/core/types.ts +19 -18
- package/src/index.ts +6 -5
- package/src/pi/config.ts +8 -7
- package/src/pi/edit-tool.ts +72 -27
- package/src/pi/execute.test.ts +15 -14
- package/src/pi/pi.test.ts +4 -4
- package/src/pi/read-tool.ts +10 -10
- package/src/pi/state.ts +13 -12
package/src/core/parse.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Strict parser: patch string → {@link ParsedPatch}.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Core principle — zero guessing: only the canonical format is accepted (correct
|
|
5
|
+
* verb + correct anchor + `+` body rows). Any variant (bare lines, legacy
|
|
6
|
+
* `oldText`/`newText`, `SWAP`/`DEL`, etc.) is rejected and left to the
|
|
7
|
+
* `transforms/normalize-legacy` middleware to normalize before parsing.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* Format:
|
|
9
10
|
*
|
|
10
11
|
* ```
|
|
11
12
|
* file: <path>
|
|
@@ -62,7 +63,7 @@ type ParsedHeader =
|
|
|
62
63
|
readonly build: (body: string[]) => Edit;
|
|
63
64
|
};
|
|
64
65
|
|
|
65
|
-
/**
|
|
66
|
+
/** Parse a single operation header (already trimmed). Trailing colon is optional (for body-bearing verbs). */
|
|
66
67
|
function parseOpHeader(s: string): ParsedHeader {
|
|
67
68
|
let core = s;
|
|
68
69
|
if (core.endsWith(":")) core = core.slice(0, -1).trimEnd();
|
|
@@ -101,10 +102,10 @@ function parseOpHeader(s: string): ParsedHeader {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/**
|
|
104
|
-
*
|
|
105
|
+
* Strictly parse a patch.
|
|
105
106
|
*
|
|
106
|
-
* @param input patch
|
|
107
|
-
* @returns
|
|
107
|
+
* @param input patch string (CRLF is normalized to LF automatically)
|
|
108
|
+
* @returns parse result; malformed input returns `ok: false` + PatchError (with the input line number)
|
|
108
109
|
*/
|
|
109
110
|
export function parsePatch(input: string): ParseResult {
|
|
110
111
|
const normalized = input.replace(/\r\n/g, "\n");
|
|
@@ -2,7 +2,7 @@ import { test } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { splitLines, joinLines, createSnapshot, verifyAnchor } from "./snapshot.ts";
|
|
4
4
|
|
|
5
|
-
test("splitLines
|
|
5
|
+
test("splitLines edge cases", () => {
|
|
6
6
|
assert.deepEqual(splitLines(""), []);
|
|
7
7
|
assert.deepEqual(splitLines("a"), ["a"]);
|
|
8
8
|
assert.deepEqual(splitLines("a\nb"), ["a", "b"]);
|
|
@@ -11,19 +11,19 @@ test("splitLines 边界", () => {
|
|
|
11
11
|
assert.deepEqual(splitLines("\n"), [""]);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
test("splitLines
|
|
14
|
+
test("splitLines strips CRLF \\r", () => {
|
|
15
15
|
assert.deepEqual(splitLines("a\r\nb\r\n"), ["a", "b"]);
|
|
16
16
|
assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test("joinLines
|
|
19
|
+
test("joinLines restores line endings", () => {
|
|
20
20
|
assert.equal(joinLines(["a", "b"]), "a\nb\n");
|
|
21
21
|
assert.equal(joinLines([]), "");
|
|
22
22
|
assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
|
|
23
23
|
assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("createSnapshot
|
|
26
|
+
test("createSnapshot records path/text/hashLen", () => {
|
|
27
27
|
const s = createSnapshot("f.ts", "a\nb\nc\n");
|
|
28
28
|
assert.equal(s.path, "f.ts");
|
|
29
29
|
assert.equal(s.text, "a\nb\nc\n");
|
|
@@ -31,16 +31,16 @@ test("createSnapshot 记录 path/text/hashLen", () => {
|
|
|
31
31
|
assert.equal(s.hashLen, 4);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
test("createSnapshot
|
|
34
|
+
test("createSnapshot custom hashLen", () => {
|
|
35
35
|
assert.equal(createSnapshot("f", "a\n", 6).hashLen, 6);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
test("createSnapshot
|
|
38
|
+
test("createSnapshot records lineEnding", () => {
|
|
39
39
|
assert.equal(createSnapshot("f", "a\nb\n").lineEnding, "lf");
|
|
40
40
|
assert.equal(createSnapshot("f", "a\r\nb\r\n").lineEnding, "crlf");
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
test("verifyAnchor
|
|
43
|
+
test("verifyAnchor match", () => {
|
|
44
44
|
const s = createSnapshot("f", "a\nb\n");
|
|
45
45
|
assert.deepEqual(verifyAnchor(s, { line: 2, hash: s.lineHashes[1] }), { ok: true, line: 2 });
|
|
46
46
|
});
|
|
@@ -52,7 +52,7 @@ test("verifyAnchor hash_not_found", () => {
|
|
|
52
52
|
if (!r.ok) assert.equal(r.error, "hash_not_found");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("verifyAnchor line_mismatch
|
|
55
|
+
test("verifyAnchor line_mismatch (drift)", () => {
|
|
56
56
|
const s = createSnapshot("f", "a\nb\n");
|
|
57
57
|
const r = verifyAnchor(s, { line: 1, hash: s.lineHashes[1] });
|
|
58
58
|
assert.equal(r.ok, false);
|
package/src/core/snapshot.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* File snapshot and anchor verification.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* A snapshot records the original text + per-line hash at read time; at apply
|
|
5
|
+
* time it verifies "current file == snapshot" (stale check) and that each
|
|
6
|
+
* anchor's hash matches its line number (guards against the model
|
|
7
|
+
* misremembering).
|
|
6
8
|
*
|
|
7
|
-
* CRLF
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* CRLF: splitLines normalizes by stripping the trailing `\r` from each line
|
|
10
|
+
* (hashes are based on clean lines, matching the `\r`-free content the model
|
|
11
|
+
* copies from the display); createSnapshot records the original line ending,
|
|
12
|
+
* and joinLines restores it per the recorded ending — guaranteeing a CRLF file
|
|
13
|
+
* keeps its line endings after edit.
|
|
10
14
|
*
|
|
11
15
|
* @module pi-hashline-edit/core
|
|
12
16
|
*/
|
|
@@ -15,11 +19,13 @@ import { hashFileLines } from "./hash.ts";
|
|
|
15
19
|
import type { Anchor, FileSnapshot, LineEnding } from "./types.ts";
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
*
|
|
22
|
+
* Split text into lines, stripping the trailing `\r` of each line (CRLF
|
|
23
|
+
* normalization, so hashes are based on clean lines).
|
|
19
24
|
*
|
|
20
|
-
*
|
|
25
|
+
* Convention: a trailing newline is treated as the terminator of the last line,
|
|
26
|
+
* not as producing an extra empty trailing line.
|
|
21
27
|
* - `"a\nb\n"` → `["a", "b"]`
|
|
22
|
-
* - `"a\r\nb\r\n"` → `["a", "b"]
|
|
28
|
+
* - `"a\r\nb\r\n"` → `["a", "b"]` (`\r` stripped)
|
|
23
29
|
* - `"a\n\n"` → `["a", ""]`
|
|
24
30
|
* - `""` → `[]`
|
|
25
31
|
*/
|
|
@@ -29,42 +35,42 @@ export function splitLines(text: string): string[] {
|
|
|
29
35
|
return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
/**
|
|
38
|
+
/** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
|
|
33
39
|
export function detectLineEnding(text: string): LineEnding {
|
|
34
40
|
return text.includes("\r\n") ? "crlf" : "lf";
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
/**
|
|
43
|
+
/** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
|
|
38
44
|
export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
|
|
39
45
|
if (lines.length === 0) return "";
|
|
40
46
|
const sep = ending === "crlf" ? "\r\n" : "\n";
|
|
41
47
|
return lines.join(sep) + sep;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
/**
|
|
50
|
+
/** Create a snapshot for a file: record original text + line ending + per-line context-aware hash. */
|
|
45
51
|
export function createSnapshot(path: string, text: string, len = 4): FileSnapshot {
|
|
46
52
|
const lines = splitLines(text);
|
|
47
53
|
const lineHashes = hashFileLines(lines, len);
|
|
48
54
|
return { path, lineHashes, text, hashLen: len, lineEnding: detectLineEnding(text) };
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
/**
|
|
57
|
+
/** Anchor verification result. */
|
|
52
58
|
export type AnchorVerifyResult =
|
|
53
59
|
| { readonly ok: true; readonly line: number }
|
|
54
60
|
| {
|
|
55
61
|
readonly ok: false;
|
|
56
62
|
readonly error: "hash_not_found" | "line_mismatch" | "collision";
|
|
57
|
-
/** hash
|
|
63
|
+
/** Line number(s) where the hash actually appears (1-based). */
|
|
58
64
|
readonly found?: readonly number[];
|
|
59
65
|
};
|
|
60
66
|
|
|
61
67
|
/**
|
|
62
|
-
*
|
|
68
|
+
* Verify an anchor against the snapshot.
|
|
63
69
|
*
|
|
64
|
-
* - hash
|
|
65
|
-
* - hash
|
|
66
|
-
* - hash
|
|
67
|
-
* - hash
|
|
70
|
+
* - hash is unique and the line number matches → `ok`
|
|
71
|
+
* - hash is unique but the line number differs → `line_mismatch` (`found` gives the real line number; drift, handled by the relocate middleware)
|
|
72
|
+
* - hash appears at multiple lines → `collision` (`found` gives all positions)
|
|
73
|
+
* - hash does not exist → `hash_not_found` (file changed, needs re-read)
|
|
68
74
|
*/
|
|
69
75
|
export function verifyAnchor(snapshot: FileSnapshot, anchor: Anchor): AnchorVerifyResult {
|
|
70
76
|
const found: number[] = [];
|
package/src/core/types.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hashline
|
|
2
|
+
* Hashline core type definitions.
|
|
3
3
|
*
|
|
4
4
|
* @module pi-hashline-edit/core
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Line anchor: dual reference of line number (1-based) + content hash. */
|
|
8
8
|
export interface Anchor {
|
|
9
9
|
readonly line: number;
|
|
10
10
|
readonly hash: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Edit operation. Every line-numbered op references a line via {@link Anchor} —
|
|
15
|
+
* the line number is for humans, the hash for machine verification; both must
|
|
16
|
+
* match the snapshot at once.
|
|
16
17
|
*/
|
|
17
18
|
export type Edit =
|
|
18
19
|
| { readonly op: "replace"; readonly start: Anchor; readonly end?: Anchor; readonly body: string[] }
|
|
@@ -24,41 +25,41 @@ export type Edit =
|
|
|
24
25
|
|
|
25
26
|
export type LineEnding = "lf" | "crlf";
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
+
/** File snapshot: original text recorded at read time + per-line context-aware hash. */
|
|
28
29
|
export interface FileSnapshot {
|
|
29
30
|
readonly path: string;
|
|
30
|
-
/** `lineHashes[i]` =
|
|
31
|
+
/** `lineHashes[i]` = hash of line (i+1); length always equals the file's line count. */
|
|
31
32
|
readonly lineHashes: readonly string[];
|
|
32
33
|
readonly text: string;
|
|
33
|
-
/**
|
|
34
|
+
/** Hash length used when generating lineHashes; apply must reuse it for the new snapshot to avoid length drift on the next verification. */
|
|
34
35
|
readonly hashLen: number;
|
|
35
|
-
/**
|
|
36
|
+
/** Original file line ending (lf/crlf); apply restores it so a CRLF file keeps its line endings after edit. */
|
|
36
37
|
readonly lineEnding: LineEnding;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
+
/** A single parsed file patch. */
|
|
40
41
|
export interface ParsedPatch {
|
|
41
42
|
readonly path: string;
|
|
42
43
|
readonly edits: Edit[];
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
+
/** Error kinds. */
|
|
46
47
|
export type PatchErrorKind =
|
|
47
|
-
| "parse" //
|
|
48
|
-
| "stale" //
|
|
49
|
-
| "anchor" //
|
|
50
|
-
| "collision" // hash
|
|
51
|
-
| "range" //
|
|
52
|
-
| "noop"; //
|
|
48
|
+
| "parse" // malformed input
|
|
49
|
+
| "stale" // file changed (current text !== snapshot.text)
|
|
50
|
+
| "anchor" // anchor hash does not match the snapshot (model misremembered) or line out of range
|
|
51
|
+
| "collision" // hash appears at multiple lines, cannot locate uniquely
|
|
52
|
+
| "range" // illegal operation range (overlap, reverse order, spanning a gap, etc.)
|
|
53
|
+
| "noop"; // edit produced no change (body byte-identical to the target)
|
|
53
54
|
|
|
54
55
|
export interface PatchError {
|
|
55
56
|
readonly kind: PatchErrorKind;
|
|
56
57
|
readonly message: string;
|
|
57
|
-
/**
|
|
58
|
+
/** Line number (1-based) in the input patch, for error localization. */
|
|
58
59
|
readonly line?: number;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
+
/** Apply result. */
|
|
62
63
|
export type ApplyResult =
|
|
63
64
|
| {
|
|
64
65
|
readonly ok: true;
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-hashline-edit
|
|
2
|
+
* pi-hashline-edit extension entry.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* edit
|
|
6
|
-
*
|
|
4
|
+
* Overrides the built-in read/edit: read outputs "lineNo#hash│content" and
|
|
5
|
+
* records a snapshot; edit accepts only a hashline patch (LINE#HASH anchors),
|
|
6
|
+
* and legacy oldText/newText is rejected explicitly (no silent degradation).
|
|
7
|
+
* The renderer is inherited from the built-in automatically.
|
|
7
8
|
*
|
|
8
9
|
* @module pi-hashline-edit
|
|
9
10
|
*/
|
|
@@ -17,7 +18,7 @@ import { makeReadOverride } from "./pi/read-tool.ts";
|
|
|
17
18
|
export default function (pi: ExtensionAPI) {
|
|
18
19
|
const cwd = process.cwd();
|
|
19
20
|
|
|
20
|
-
// session
|
|
21
|
+
// refresh config and snapshots on session start / reload
|
|
21
22
|
pi.on("session_start", async () => {
|
|
22
23
|
const config = loadConfig(cwd);
|
|
23
24
|
const state = getState();
|
package/src/pi/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Config loading: project `.pi/settings.json` replaces global, per-field `??`
|
|
3
|
+
* falls back to DEFAULT. Config field `hashlineEdit` (drop the `pi-` prefix,
|
|
4
|
+
* camelCase).
|
|
4
5
|
*
|
|
5
6
|
* @module pi-hashline-edit/pi
|
|
6
7
|
*/
|
|
@@ -10,9 +11,9 @@ import * as os from "node:os";
|
|
|
10
11
|
import * as path from "node:path";
|
|
11
12
|
|
|
12
13
|
export interface HashlineEditConfig {
|
|
13
|
-
/**
|
|
14
|
+
/** Whether hashline is enabled (when false, delegate to the built-in read/edit). */
|
|
14
15
|
enabled: boolean;
|
|
15
|
-
/**
|
|
16
|
+
/** Line hash length (default 4). */
|
|
16
17
|
hashLen: number;
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -24,7 +25,7 @@ function getAgentDir(): string {
|
|
|
24
25
|
return path.join(os.homedir(), ".pi", "agent");
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
+
/** Parse JSON directly without stripping comments (standard JSON forbids comments; on error fall back to default). */
|
|
28
29
|
function readSettings(filePath: string): Record<string, unknown> {
|
|
29
30
|
try {
|
|
30
31
|
if (!fs.existsSync(filePath)) return {};
|
|
@@ -35,8 +36,8 @@ function readSettings(filePath: string): Record<string, unknown> {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
39
|
+
* Load config. The `hashlineEdit` in project `cwd/.pi/settings.json` replaces
|
|
40
|
+
* the global one wholesale; missing fields fall back to DEFAULT_CONFIG.
|
|
40
41
|
*/
|
|
41
42
|
export function loadConfig(cwd?: string): HashlineEditConfig {
|
|
42
43
|
const globalSettings = readSettings(path.join(getAgentDir(), "settings.json"));
|
package/src/pi/edit-tool.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Override edit
|
|
2
|
+
* Override edit: accepts only a hashline patch (`input`).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Not backward-compatible with legacy oldText/newText — when legacy input is
|
|
5
|
+
* detected it errors explicitly so the developer knows the model didn't use the
|
|
6
|
+
* new approach, rather than silently degrading. Tolerance is limited to
|
|
7
|
+
* format-only normalizations that don't affect the result (optional colon, CRLF
|
|
8
|
+
* at the parse layer); paradigm-level compatibility is refused outright.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Concurrency safety: the read-modify-write is wrapped in
|
|
11
|
+
* withFileMutationQueue, serializing multiple edits to the same file to prevent
|
|
12
|
+
* data loss under pi's default parallel execution. AbortSignal is honored —
|
|
13
|
+
* checked after read / before write, so a user cancel never touches the disk.
|
|
11
14
|
*
|
|
12
15
|
* @module pi-hashline-edit/pi
|
|
13
16
|
*/
|
|
@@ -19,10 +22,13 @@ import { applyEdits, parsePatch } from "../core/index.ts";
|
|
|
19
22
|
import type { Edit, FileSnapshot, PatchError } from "../core/types.ts";
|
|
20
23
|
import { canonicalPath } from "./read-tool.ts";
|
|
21
24
|
import { getState, getSnapshot, putSnapshot, recordSnapshot } from "./state.ts";
|
|
25
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
22
26
|
|
|
23
|
-
// schema
|
|
24
|
-
//
|
|
25
|
-
//
|
|
27
|
+
// The schema deliberately omits additionalProperties:false: when the model
|
|
28
|
+
// mistakenly sends legacy edits/oldText/newText, those extra fields reach
|
|
29
|
+
// execute as-is and are caught and rejected explicitly by missingInputError.
|
|
30
|
+
// This relies on typebox allowing extra properties by default + pi validation
|
|
31
|
+
// not stripping them — do not change either of these.
|
|
26
32
|
|
|
27
33
|
const editSchema = Type.Object({
|
|
28
34
|
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
|
@@ -34,7 +40,7 @@ const editSchema = Type.Object({
|
|
|
34
40
|
),
|
|
35
41
|
});
|
|
36
42
|
|
|
37
|
-
/**
|
|
43
|
+
/** Turn a PatchError into helpful hint text for the model. */
|
|
38
44
|
function errorText(e: PatchError, path: string): string {
|
|
39
45
|
switch (e.kind) {
|
|
40
46
|
case "stale":
|
|
@@ -52,7 +58,7 @@ function errorText(e: PatchError, path: string): string {
|
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
/**
|
|
61
|
+
/** Get the first changed line after applying (for firstChangedLine / TUI jump). insert_after uses anchor + 1. */
|
|
56
62
|
function minAnchorLine(edits: readonly Edit[]): number | undefined {
|
|
57
63
|
let min: number | undefined;
|
|
58
64
|
for (const e of edits) {
|
|
@@ -61,15 +67,16 @@ function minAnchorLine(edits: readonly Edit[]): number | undefined {
|
|
|
61
67
|
else if (e.op === "insert_after") line = e.anchor.line + 1;
|
|
62
68
|
else if (e.op === "insert_before") line = e.anchor.line;
|
|
63
69
|
else if (e.op === "prepend") line = 1;
|
|
64
|
-
// append
|
|
70
|
+
// append has no anchor, skip (trailing append)
|
|
65
71
|
if (line !== undefined && (min === undefined || line < min)) min = line;
|
|
66
72
|
}
|
|
67
73
|
return min;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
77
|
+
* The model didn't use hashline (sent legacy oldText/newText or is missing
|
|
78
|
+
* input) → tell it explicitly, don't silently degrade. Exported for testing.
|
|
79
|
+
* params is `any` so it can detect legacy fields outside the schema.
|
|
73
80
|
*/
|
|
74
81
|
export function missingInputError(path: string, params: any): string {
|
|
75
82
|
const legacy =
|
|
@@ -82,7 +89,7 @@ export function missingInputError(path: string, params: any): string {
|
|
|
82
89
|
return `Edit ${path}: missing \`input\` (hashline patch). Read ${path} first, then send \`input\` referencing LINE#HASH anchors.`;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
/**
|
|
92
|
+
/** Build an error result (with isError: true so the TUI/agent loop treats it as a failure, not a success). */
|
|
86
93
|
function errResult(text: string) {
|
|
87
94
|
return {
|
|
88
95
|
isError: true as const,
|
|
@@ -110,20 +117,56 @@ export function makeEditOverride(cwd: string) {
|
|
|
110
117
|
parameters: editSchema,
|
|
111
118
|
renderShell: "self" as const,
|
|
112
119
|
|
|
120
|
+
renderCall(args: Static<typeof editSchema>, theme: any) {
|
|
121
|
+
let text = theme.fg("toolTitle", theme.bold("edit "));
|
|
122
|
+
text += theme.fg("accent", args.path);
|
|
123
|
+
if (args.input) {
|
|
124
|
+
const firstOp = args.input.split("\n").find((l) => l.trim() && !l.startsWith("+"));
|
|
125
|
+
if (firstOp) text += theme.fg("dim", ` — ${firstOp.trim()}`);
|
|
126
|
+
}
|
|
127
|
+
return new Text(text, 0, 0);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
renderResult(result: any, { isPartial, expanded }: any, theme: any) {
|
|
131
|
+
if (isPartial) return new Text(theme.fg("warning", "Editing…"), 0, 0);
|
|
132
|
+
const content = result.content?.[0];
|
|
133
|
+
if (result.isError) {
|
|
134
|
+
const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
|
|
135
|
+
return new Text(theme.fg("error", t), 0, 0);
|
|
136
|
+
}
|
|
137
|
+
const diff: string | undefined = result.details?.diff;
|
|
138
|
+
if (!diff) {
|
|
139
|
+
const t = content?.type === "text" ? content.text : "Edited";
|
|
140
|
+
return new Text(theme.fg("success", t), 0, 0);
|
|
141
|
+
}
|
|
142
|
+
// details.diff is pi-format (+N/-N/<space>N content); color by leading char
|
|
143
|
+
const allLines = diff.split("\n");
|
|
144
|
+
const shown = expanded ? allLines : allLines.slice(0, 24);
|
|
145
|
+
const body = shown
|
|
146
|
+
.map((line: string) => {
|
|
147
|
+
if (line.startsWith("+")) return theme.fg("success", line);
|
|
148
|
+
if (line.startsWith("-")) return theme.fg("error", line);
|
|
149
|
+
return theme.fg("dim", line);
|
|
150
|
+
})
|
|
151
|
+
.join("\n");
|
|
152
|
+
const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
|
|
153
|
+
return new Text(body + more, 0, 0);
|
|
154
|
+
},
|
|
155
|
+
|
|
113
156
|
async execute(toolCallId: string, params: Static<typeof editSchema>, signal: AbortSignal | undefined, onUpdate: any) {
|
|
114
157
|
const state = getState();
|
|
115
|
-
//
|
|
158
|
+
// hashline disabled by the user (config.enabled=false) → delegate to the built-in
|
|
116
159
|
if (!state.config.enabled) return builtin.execute(toolCallId, params as any, signal, onUpdate);
|
|
117
160
|
|
|
118
161
|
const path = params.path;
|
|
119
162
|
const absPath = canonicalPath(cwd, path);
|
|
120
163
|
const input = params.input;
|
|
121
164
|
|
|
122
|
-
//
|
|
165
|
+
// No input → tell explicitly (distinguish legacy vs missing), don't silently degrade
|
|
123
166
|
if (typeof input !== "string" || input.trim() === "") {
|
|
124
167
|
return errResult(missingInputError(path, params as any));
|
|
125
168
|
}
|
|
126
|
-
// withFileMutationQueue
|
|
169
|
+
// withFileMutationQueue serializes read-modify-write for the same file, preventing parallel-edit data loss
|
|
127
170
|
return await withFileMutationQueue(absPath, () => runHashline(absPath, path, input, signal));
|
|
128
171
|
},
|
|
129
172
|
};
|
|
@@ -137,14 +180,15 @@ async function runHashline(absPath: string, displayPath: string, input: string,
|
|
|
137
180
|
const msg = e instanceof Error ? e.message : String(e);
|
|
138
181
|
return errResult(`Error reading ${displayPath}: ${msg}`);
|
|
139
182
|
}
|
|
140
|
-
//
|
|
183
|
+
// Check for cancel after read: if the user aborted, don't proceed to parse/apply; the file stays untouched
|
|
141
184
|
if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before apply.`);
|
|
142
185
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
186
|
+
// Use the recorded snapshot, or if there is none (the model didn't read) build one from the
|
|
187
|
+
// current file — anchor verification still forces the model to use a real hash (it can't guess
|
|
188
|
+
// correctly without reading), naturally steering it to read first.
|
|
145
189
|
const snap: FileSnapshot = getSnapshot(absPath) ?? recordSnapshot(absPath, currentText);
|
|
146
190
|
|
|
147
|
-
// input
|
|
191
|
+
// input has no file header; prepend one so parsePatch passes (path is just a label)
|
|
148
192
|
const parsed = parsePatch(`file: ${absPath}\n\n${input}`);
|
|
149
193
|
if (!parsed.ok) {
|
|
150
194
|
return errResult(errorText(parsed.error, displayPath));
|
|
@@ -155,7 +199,7 @@ async function runHashline(absPath: string, displayPath: string, input: string,
|
|
|
155
199
|
return errResult(errorText(result.error, displayPath));
|
|
156
200
|
}
|
|
157
201
|
|
|
158
|
-
//
|
|
202
|
+
// Check for cancel before write: if aborted, don't touch the disk; the file stays untouched
|
|
159
203
|
if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before write.`);
|
|
160
204
|
|
|
161
205
|
try {
|
|
@@ -165,7 +209,7 @@ async function runHashline(absPath: string, displayPath: string, input: string,
|
|
|
165
209
|
return errResult(`Error writing ${displayPath}: ${msg}`);
|
|
166
210
|
}
|
|
167
211
|
|
|
168
|
-
//
|
|
212
|
+
// Update the snapshot: consecutive edits need no re-read (result.newSnapshot is based on the new text), via LRU
|
|
169
213
|
putSnapshot(absPath, result.newSnapshot);
|
|
170
214
|
|
|
171
215
|
return {
|
|
@@ -173,8 +217,9 @@ async function runHashline(absPath: string, displayPath: string, input: string,
|
|
|
173
217
|
{ type: "text" as const, text: `Edited ${displayPath} (${parsed.patch.edits.length} op(s)).` },
|
|
174
218
|
],
|
|
175
219
|
details: {
|
|
176
|
-
// details.diff
|
|
177
|
-
//
|
|
220
|
+
// details.diff must use pi's generateDiffString (+N content format); the built-in
|
|
221
|
+
// renderer's parseDiffLine only recognizes this format — core's standard unified diff
|
|
222
|
+
// would be grayed out as plain text
|
|
178
223
|
diff: generateDiffString(currentText, result.text),
|
|
179
224
|
patch: generateUnifiedPatch(displayPath, currentText, result.text),
|
|
180
225
|
firstChangedLine: minAnchorLine(parsed.patch.edits),
|
package/src/pi/execute.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi
|
|
3
|
-
* execute
|
|
2
|
+
* Integration tests for the pi integration layer's execute: drives the real
|
|
3
|
+
* makeReadOverride/makeEditOverride execute, covering text read with anchors,
|
|
4
|
+
* the hashline edit closed loop, and error returns with isError.
|
|
4
5
|
*/
|
|
5
6
|
import { test } from "node:test";
|
|
6
7
|
import assert from "node:assert/strict";
|
|
@@ -24,7 +25,7 @@ async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
|
24
25
|
|
|
25
26
|
const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
|
|
26
27
|
|
|
27
|
-
test("read execute
|
|
28
|
+
test("read execute: text outputs LINE#HASH│content", async () => {
|
|
28
29
|
await withDir(async (dir) => {
|
|
29
30
|
await writeFile(join(dir, "f.txt"), "line1\nline2\n");
|
|
30
31
|
const read = makeReadOverride(dir);
|
|
@@ -37,18 +38,18 @@ test("read execute:文本输出 LINE#HASH│content", async () => {
|
|
|
37
38
|
});
|
|
38
39
|
});
|
|
39
40
|
|
|
40
|
-
test("read execute
|
|
41
|
+
test("read execute: records a snapshot for edit", async () => {
|
|
41
42
|
await withDir(async (dir) => {
|
|
42
43
|
await writeFile(join(dir, "f.txt"), "a\nb\n");
|
|
43
44
|
const read = makeReadOverride(dir);
|
|
44
45
|
await call(read, { path: "f.txt" });
|
|
45
46
|
const snap = getSnapshot(join(dir, "f.txt"));
|
|
46
|
-
assert.ok(snap, "snapshot
|
|
47
|
+
assert.ok(snap, "snapshot not recorded");
|
|
47
48
|
assert.equal(snap!.lineHashes.length, 2);
|
|
48
49
|
});
|
|
49
50
|
});
|
|
50
51
|
|
|
51
|
-
test("edit execute
|
|
52
|
+
test("edit execute: hashline closed loop (read → edit → file changed)", async () => {
|
|
52
53
|
await withDir(async (dir) => {
|
|
53
54
|
const f = join(dir, "f.txt");
|
|
54
55
|
await writeFile(f, "a\nb\nc\n");
|
|
@@ -59,12 +60,12 @@ test("edit execute:hashline 闭环(read → edit → 文件改)", async ()
|
|
|
59
60
|
path: "f.txt",
|
|
60
61
|
input: `replace 2#${snap.lineHashes[1]}:\n+B`,
|
|
61
62
|
});
|
|
62
|
-
assert.equal(r.isError, undefined, "
|
|
63
|
+
assert.equal(r.isError, undefined, "should not be an error");
|
|
63
64
|
assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
|
|
64
65
|
});
|
|
65
66
|
});
|
|
66
67
|
|
|
67
|
-
test("edit execute
|
|
68
|
+
test("edit execute: consecutive edits reuse the updated snapshot", async () => {
|
|
68
69
|
await withDir(async (dir) => {
|
|
69
70
|
const f = join(dir, "f.txt");
|
|
70
71
|
await writeFile(f, "a\nb\n");
|
|
@@ -73,7 +74,7 @@ test("edit execute:连续 edit 复用更新后的 snapshot", async () => {
|
|
|
73
74
|
await call(read, { path: "f.txt" });
|
|
74
75
|
let snap = getSnapshot(f)!;
|
|
75
76
|
await call(edit, { path: "f.txt", input: `replace 1#${snap.lineHashes[0]}:\n+A` });
|
|
76
|
-
//
|
|
77
|
+
// second edit: the snapshot was updated by the edit, use the new hash
|
|
77
78
|
snap = getSnapshot(f)!;
|
|
78
79
|
const r: any = await call(edit, { path: "f.txt", input: `replace 2#${snap.lineHashes[1]}:\n+B` });
|
|
79
80
|
assert.equal(r.isError, undefined);
|
|
@@ -81,17 +82,17 @@ test("edit execute:连续 edit 复用更新后的 snapshot", async () => {
|
|
|
81
82
|
});
|
|
82
83
|
});
|
|
83
84
|
|
|
84
|
-
test("edit execute
|
|
85
|
+
test("edit execute: edit without a prior read → anchor verification fails", async () => {
|
|
85
86
|
await withDir(async (dir) => {
|
|
86
87
|
await writeFile(join(dir, "f.txt"), "a\nb\n");
|
|
87
88
|
const edit = makeEditOverride(dir);
|
|
88
|
-
//
|
|
89
|
+
// never read, so the hash is made up
|
|
89
90
|
const r: any = await call(edit, { path: "f.txt", input: "replace 1#XXXX:\n+A" });
|
|
90
91
|
assert.equal(r.isError, true);
|
|
91
92
|
});
|
|
92
93
|
});
|
|
93
94
|
|
|
94
|
-
test("edit execute
|
|
95
|
+
test("edit execute: missing input → isError + missing hint", async () => {
|
|
95
96
|
await withDir(async (dir) => {
|
|
96
97
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
97
98
|
const r: any = await call(makeEditOverride(dir), { path: "f.txt" });
|
|
@@ -100,7 +101,7 @@ test("edit execute:缺 input → isError + missing 提示", async () => {
|
|
|
100
101
|
});
|
|
101
102
|
});
|
|
102
103
|
|
|
103
|
-
test("edit execute
|
|
104
|
+
test("edit execute: legacy oldText/newText → isError + legacy hint", async () => {
|
|
104
105
|
await withDir(async (dir) => {
|
|
105
106
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
106
107
|
const r: any = await call(makeEditOverride(dir), {
|
|
@@ -113,7 +114,7 @@ test("edit execute:旧 oldText/newText → isError + legacy 提示", async ()
|
|
|
113
114
|
});
|
|
114
115
|
});
|
|
115
116
|
|
|
116
|
-
test("edit execute
|
|
117
|
+
test("edit execute: parse error → isError", async () => {
|
|
117
118
|
await withDir(async (dir) => {
|
|
118
119
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
119
120
|
await call(makeReadOverride(dir), { path: "f.txt" });
|