@d3ara1n/pi-hashline-edit 0.1.0 → 0.1.2
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 +20 -21
- package/package.json +1 -1
- package/src/core/apply.test.ts +89 -101
- package/src/core/apply.ts +58 -47
- package/src/core/hash.test.ts +21 -16
- package/src/core/hash.ts +35 -57
- package/src/core/index.ts +4 -8
- package/src/core/lines.test.ts +30 -0
- package/src/core/lines.ts +42 -0
- package/src/core/types.ts +16 -34
- package/src/index.ts +8 -10
- package/src/pi/config.ts +8 -7
- package/src/pi/edit-tool.ts +159 -96
- package/src/pi/execute.test.ts +194 -47
- package/src/pi/pi.test.ts +1 -20
- package/src/pi/read-tool.ts +16 -19
- package/src/pi/state.ts +4 -57
- package/src/core/diff.test.ts +0 -21
- package/src/core/diff.ts +0 -39
- package/src/core/parse.test.ts +0 -117
- package/src/core/parse.ts +0 -165
- package/src/core/snapshot.test.ts +0 -60
- package/src/core/snapshot.ts +0 -78
package/src/core/hash.ts
CHANGED
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Per-line content hash.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The hash mixes the 1-based line number into the line content, so every line
|
|
5
|
+
* gets a unique hash by construction — line numbers are unique, therefore no
|
|
6
|
+
* in-file collision is possible, and no length extension / fallback is ever
|
|
7
|
+
* needed.
|
|
8
|
+
*
|
|
9
|
+
* Why line + content (not content alone, not content + neighbors):
|
|
10
|
+
*
|
|
11
|
+
* - The line number is the address; the hash is a checksum that the line at
|
|
12
|
+
* that address is still what was read. Mixing the number in makes the hash a
|
|
13
|
+
* pure fingerprint of (position, content): it changes only when the line's
|
|
14
|
+
* own content changes, never when a neighbor changes. (A neighbor-aware hash
|
|
15
|
+
* would change an unchanged line's hash when an adjacent line is edited — a
|
|
16
|
+
* spurious dependency with no benefit under this design's position-fixed
|
|
17
|
+
* apply.)
|
|
18
|
+
* - Content alone would leave identical lines (blank lines, `}`) sharing a
|
|
19
|
+
* hash; mixing the line number disambiguates them for free.
|
|
20
|
+
*
|
|
21
|
+
* Drift (file changed since read) is caught up-front by the global stale check
|
|
22
|
+
* (`text !== snapshot.text`). This hash's job is to verify the model actually
|
|
23
|
+
* read the line — it cannot forge a `(line, content)` hash without reading.
|
|
7
24
|
*
|
|
8
25
|
* @module pi-hashline-edit/core
|
|
9
26
|
*/
|
|
10
27
|
|
|
11
|
-
/** Crockford base32
|
|
28
|
+
/** Crockford base32 alphabet (without I/L/O/U to avoid ambiguous characters). Exactly 32 characters. */
|
|
12
29
|
const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
13
30
|
|
|
14
31
|
/**
|
|
15
|
-
* FNV-1a 32-bit
|
|
16
|
-
*
|
|
32
|
+
* FNV-1a 32-bit. Stable (same input always yields the same output), evenly
|
|
33
|
+
* distributed, non-cryptographic. Uses `Math.imul` for correct 32-bit integer
|
|
34
|
+
* multiplication under JS.
|
|
17
35
|
*/
|
|
18
36
|
function fnv1a32(str: string): number {
|
|
19
37
|
let h = 0x811c9dc5;
|
|
@@ -24,7 +42,7 @@ function fnv1a32(str: string): number {
|
|
|
24
42
|
return h >>> 0;
|
|
25
43
|
}
|
|
26
44
|
|
|
27
|
-
/**
|
|
45
|
+
/** Encode a 32-bit integer into a base32 string of the given length. */
|
|
28
46
|
function toBase32(n: number, len: number): string {
|
|
29
47
|
let s = "";
|
|
30
48
|
for (let i = 0; i < len; i++) {
|
|
@@ -35,60 +53,20 @@ function toBase32(n: number, len: number): string {
|
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
/**
|
|
38
|
-
*
|
|
56
|
+
* Compute the hash of a single line from its 1-based line number and content.
|
|
39
57
|
*
|
|
40
|
-
* @param
|
|
41
|
-
* @param
|
|
42
|
-
* @param
|
|
43
|
-
* @param len hash 长度(默认 4,20 bits ≈ 100 万值)
|
|
58
|
+
* @param line 1-based line number
|
|
59
|
+
* @param content the line's content (no line terminator)
|
|
60
|
+
* @param len hash length (default 4, 20 bits ≈ 1M values)
|
|
44
61
|
*/
|
|
45
|
-
export function computeLineHash(
|
|
46
|
-
|
|
47
|
-
return toBase32(h, len);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** 给每行算 raw hash(不处理碰撞)。 */
|
|
51
|
-
function rawHashes(lines: readonly string[], len: number): string[] {
|
|
52
|
-
return lines.map((line, i) =>
|
|
53
|
-
computeLineHash(lines[i - 1] ?? "", line, lines[i + 1] ?? "", len),
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** 找出出现 >1 次的 hash 值。 */
|
|
58
|
-
function duplicatedHashes(hashes: readonly string[]): Set<string> {
|
|
59
|
-
const counts = new Map<string, number>();
|
|
60
|
-
for (const h of hashes) counts.set(h, (counts.get(h) ?? 0) + 1);
|
|
61
|
-
return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([h]) => h));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** 兜底:用序号后缀强制唯一(context-aware 下理论不可达)。 */
|
|
65
|
-
function forceUnique(hashes: string[]): string[] {
|
|
66
|
-
const seen = new Map<string, number>();
|
|
67
|
-
return hashes.map((h) => {
|
|
68
|
-
const c = seen.get(h) ?? 0;
|
|
69
|
-
seen.set(h, c + 1);
|
|
70
|
-
return c === 0 ? h : `${h}${c}`;
|
|
71
|
-
});
|
|
62
|
+
export function computeLineHash(line: number, content: string, len = 4): string {
|
|
63
|
+
return toBase32(fnv1a32(`${line}\n${content}`), len);
|
|
72
64
|
}
|
|
73
65
|
|
|
74
66
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* 设计依据:context-aware 已使碰撞概率接近 0;此处扩展是防御性兜底,
|
|
79
|
-
* 保证 apply 永远不会因 hash 歧义而误定位。
|
|
67
|
+
* Compute per-line hashes for a file. Unique by construction — the 1-based line
|
|
68
|
+
* number is part of each hash, so two identical content lines always differ.
|
|
80
69
|
*/
|
|
81
70
|
export function hashFileLines(lines: readonly string[], len = 4): string[] {
|
|
82
|
-
|
|
83
|
-
let hashes = rawHashes(lines, len);
|
|
84
|
-
for (let curLen = len; curLen <= len + 4; curLen++) {
|
|
85
|
-
const dups = duplicatedHashes(hashes);
|
|
86
|
-
if (dups.size === 0) return hashes;
|
|
87
|
-
hashes = hashes.map((h, i) =>
|
|
88
|
-
dups.has(h)
|
|
89
|
-
? computeLineHash(lines[i - 1] ?? "", lines[i] ?? "", lines[i + 1] ?? "", curLen + 1)
|
|
90
|
-
: h,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
return forceUnique(hashes);
|
|
71
|
+
return lines.map((content, i) => computeLineHash(i + 1, content, len));
|
|
94
72
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-hashline-edit
|
|
2
|
+
* Public API of the pi-hashline-edit core library.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* pi
|
|
4
|
+
* The pure hashline engine, with zero pi dependencies, runnable standalone via
|
|
5
|
+
* `node --test`. The pi integration layer lives under `../pi/`.
|
|
6
6
|
*
|
|
7
7
|
* @module pi-hashline-edit/core
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export * from "./types.ts";
|
|
11
11
|
export { computeLineHash, hashFileLines } from "./hash.ts";
|
|
12
|
-
export { splitLines, joinLines,
|
|
13
|
-
export type { AnchorVerifyResult } from "./snapshot.ts";
|
|
14
|
-
export { parsePatch } from "./parse.ts";
|
|
15
|
-
export type { ParseResult } from "./parse.ts";
|
|
12
|
+
export { splitLines, joinLines, detectLineEnding } from "./lines.ts";
|
|
16
13
|
export { applyEdits } from "./apply.ts";
|
|
17
|
-
export { buildDiff } from "./diff.ts";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { splitLines, joinLines, detectLineEnding } from "./lines.ts";
|
|
4
|
+
|
|
5
|
+
test("splitLines edge cases", () => {
|
|
6
|
+
assert.deepEqual(splitLines(""), []);
|
|
7
|
+
assert.deepEqual(splitLines("a"), ["a"]);
|
|
8
|
+
assert.deepEqual(splitLines("a\nb"), ["a", "b"]);
|
|
9
|
+
assert.deepEqual(splitLines("a\nb\n"), ["a", "b"]);
|
|
10
|
+
assert.deepEqual(splitLines("a\n\n"), ["a", ""]);
|
|
11
|
+
assert.deepEqual(splitLines("\n"), [""]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("splitLines strips CRLF \\r", () => {
|
|
15
|
+
assert.deepEqual(splitLines("a\r\nb\r\n"), ["a", "b"]);
|
|
16
|
+
assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("joinLines restores line endings", () => {
|
|
20
|
+
assert.equal(joinLines(["a", "b"]), "a\nb\n");
|
|
21
|
+
assert.equal(joinLines([]), "");
|
|
22
|
+
assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
|
|
23
|
+
assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("detectLineEnding", () => {
|
|
27
|
+
assert.equal(detectLineEnding("a\nb\n"), "lf");
|
|
28
|
+
assert.equal(detectLineEnding("a\r\nb\r\n"), "crlf");
|
|
29
|
+
assert.equal(detectLineEnding("a\nb\r\nc\n"), "crlf");
|
|
30
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line text helpers: split/join with CRLF normalization and line-ending
|
|
3
|
+
* detection.
|
|
4
|
+
*
|
|
5
|
+
* CRLF: splitLines strips the trailing `\r` from each line (hashes are based on
|
|
6
|
+
* clean lines, matching the `\r`-free content the model copies from the
|
|
7
|
+
* display); detectLineEnding records the original ending so joinLines can
|
|
8
|
+
* restore it — guaranteeing a CRLF file keeps its endings after edit.
|
|
9
|
+
*
|
|
10
|
+
* @module pi-hashline-edit/core
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LineEnding } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split text into lines, stripping the trailing `\r` of each line (CRLF
|
|
17
|
+
* normalization, so hashes are based on clean lines).
|
|
18
|
+
*
|
|
19
|
+
* Convention: a trailing newline is treated as the terminator of the last line,
|
|
20
|
+
* not as producing an extra empty trailing line.
|
|
21
|
+
* - `"a\nb\n"` → `["a", "b"]`
|
|
22
|
+
* - `"a\r\nb\r\n"` → `["a", "b"]` (`\r` stripped)
|
|
23
|
+
* - `"a\n\n"` → `["a", ""]`
|
|
24
|
+
* - `""` → `[]`
|
|
25
|
+
*/
|
|
26
|
+
export function splitLines(text: string): string[] {
|
|
27
|
+
if (text === "") return [];
|
|
28
|
+
const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
29
|
+
return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
|
|
33
|
+
export function detectLineEnding(text: string): LineEnding {
|
|
34
|
+
return text.includes("\r\n") ? "crlf" : "lf";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
|
|
38
|
+
export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
|
|
39
|
+
if (lines.length === 0) return "";
|
|
40
|
+
const sep = ending === "crlf" ? "\r\n" : "\n";
|
|
41
|
+
return lines.join(sep) + sep;
|
|
42
|
+
}
|
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 the address, the hash a checksum that the line at that
|
|
16
|
+
* address is still what was read; both must match at apply time.
|
|
16
17
|
*/
|
|
17
18
|
export type Edit =
|
|
18
19
|
| { readonly op: "replace"; readonly start: Anchor; readonly end?: Anchor; readonly body: string[] }
|
|
@@ -24,47 +25,28 @@ export type Edit =
|
|
|
24
25
|
|
|
25
26
|
export type LineEnding = "lf" | "crlf";
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
-
export interface FileSnapshot {
|
|
29
|
-
readonly path: string;
|
|
30
|
-
/** `lineHashes[i]` = 第 (i+1) 行的 hash,长度恒等于文件行数。 */
|
|
31
|
-
readonly lineHashes: readonly string[];
|
|
32
|
-
readonly text: string;
|
|
33
|
-
/** 生成 lineHashes 时用的 hash 长度;apply 生成新快照时须沿用,避免长度不一致导致下次校验失败。 */
|
|
34
|
-
readonly hashLen: number;
|
|
35
|
-
/** 原文件行尾(lf/crlf);apply 据此恢复,保证 CRLF 文件 edit 后行尾不变。 */
|
|
36
|
-
readonly lineEnding: LineEnding;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** 解析出的单个文件 patch。 */
|
|
40
|
-
export interface ParsedPatch {
|
|
41
|
-
readonly path: string;
|
|
42
|
-
readonly edits: Edit[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** 错误种类。 */
|
|
28
|
+
/** Error kinds. */
|
|
46
29
|
export type PatchErrorKind =
|
|
47
|
-
| "
|
|
48
|
-
| "
|
|
49
|
-
| "
|
|
50
|
-
| "collision" // hash 在文件中多处出现,无法唯一定位
|
|
51
|
-
| "range" // 操作范围非法(重叠、逆序、跨空等)
|
|
52
|
-
| "noop"; // 编辑未产生变化(body 与目标行字节相同)
|
|
30
|
+
| "anchor" // anchor hash does not match the current line content (line changed, or model misremembered) or line out of range
|
|
31
|
+
| "range" // illegal operation range (overlap, reverse order, etc.)
|
|
32
|
+
| "noop"; // edit produced no change (body byte-identical to the target)
|
|
53
33
|
|
|
54
34
|
export interface PatchError {
|
|
55
35
|
readonly kind: PatchErrorKind;
|
|
56
36
|
readonly message: string;
|
|
57
|
-
/** 输入 patch 中的行号(1-based),用于错误定位。 */
|
|
58
|
-
readonly line?: number;
|
|
59
37
|
}
|
|
60
38
|
|
|
61
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Apply result. On success, `touchedLines` lists the 0-based line indices in
|
|
41
|
+
* the NEW file that this edit produced (inserted or replaced) — callers use it
|
|
42
|
+
* to surface fresh `LINE#HASH` anchors so the model can chain edits without a
|
|
43
|
+
* re-read.
|
|
44
|
+
*/
|
|
62
45
|
export type ApplyResult =
|
|
63
46
|
| {
|
|
64
47
|
readonly ok: true;
|
|
65
48
|
readonly text: string;
|
|
66
|
-
readonly newSnapshot: FileSnapshot;
|
|
67
49
|
readonly changed: boolean;
|
|
68
|
-
readonly
|
|
50
|
+
readonly touchedLines: readonly number[];
|
|
69
51
|
}
|
|
70
52
|
| { readonly ok: false; readonly error: PatchError };
|
package/src/index.ts
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
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";
|
|
5
|
+
* edit accepts structured hashline ops (edits[] with LINE#HASH anchors), and
|
|
6
|
+
* legacy oldText/newText is rejected explicitly (no silent degradation). Each
|
|
7
|
+
* tool carries its own renderer.
|
|
7
8
|
*
|
|
8
9
|
* @module pi-hashline-edit
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { loadConfig } from "./pi/config.ts";
|
|
13
|
-
import {
|
|
14
|
+
import { getState } from "./pi/state.ts";
|
|
14
15
|
import { makeEditOverride } from "./pi/edit-tool.ts";
|
|
15
16
|
import { makeReadOverride } from "./pi/read-tool.ts";
|
|
16
17
|
|
|
17
18
|
export default function (pi: ExtensionAPI) {
|
|
18
19
|
const cwd = process.cwd();
|
|
19
20
|
|
|
20
|
-
// session
|
|
21
|
+
// refresh config on session start / reload
|
|
21
22
|
pi.on("session_start", async () => {
|
|
22
|
-
const config = loadConfig(cwd);
|
|
23
23
|
const state = getState();
|
|
24
|
-
state.config =
|
|
25
|
-
state.hashLen = config.hashLen;
|
|
26
|
-
clearSnapshots();
|
|
24
|
+
state.config = loadConfig(cwd);
|
|
27
25
|
});
|
|
28
26
|
|
|
29
27
|
pi.registerTool(makeReadOverride(cwd));
|
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"));
|