@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/src/core/hash.ts CHANGED
@@ -1,19 +1,37 @@
1
1
  /**
2
- * 行级 context-aware hash
2
+ * Per-line content hash.
3
3
  *
4
- * 每行的 hash 把「上一行 + 本行 + 下一行」拼起来一起算,使内容相同的行
5
- * (空行、`}`、`return`)因邻居不同而 hash 不同,文件内碰撞实际接近 0。
6
- * 残余碰撞由 {@link hashFileLines} 自动扩展长度解决(per-file 无碰撞保证)。
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 字符表(去 I/L/O/U,避免易混字符)。正好 32 个。 */
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
- * `Math.imul` 保证 32-bit 整数乘法在 JS 下正确。
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
- /** 32-bit 整数编码为指定长度的 base32 字符串。 */
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
- * 计算单行的 context-aware hash。
56
+ * Compute the hash of a single line from its 1-based line number and content.
39
57
  *
40
- * @param prev 上一行内容(首行传 `""`)
41
- * @param cur 本行内容
42
- * @param next 下一行内容(末行传 `""`)
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(prev: string, cur: string, next: string, len = 4): string {
46
- const h = fnv1a32(`${prev}\n${cur}\n${next}`);
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
- * 给整个文件的每行算 hash,并解决文件内碰撞:
76
- * 碰撞的行自动用更长 len 重算,直到文件内唯一(per-file 无碰撞保证)。
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
- if (lines.length === 0) return [];
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 核心库公共 API。
2
+ * Public API of the pi-hashline-edit core library.
3
3
  *
4
- * hashline 引擎,零 pi 依赖,可独立 `node --test`。
5
- * pi 接入层在 `../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, createSnapshot, verifyAnchor } from "./snapshot.ts";
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
- /** 行锚:行号(1-based)+ 内容 hash 双重引用。 */
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
- * 编辑操作。所有带行号的操作都通过 {@link Anchor} 引用——
15
- * 行号给人读,hash 给机器校验,二者必须同时匹配快照。
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
- /** 文件快照:read 时记录的原文 + 每行 context-aware hash。 */
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
- | "parse" // 输入格式错误
48
- | "stale" // 文件已变(当前 text !== snapshot.text)
49
- | "anchor" // hash 不匹配快照(模型记错)或行号越界
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 diff: string;
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
- * override 内置 read/editread 输出「行号#hash│内容」并记录快照,
5
- * edit 只接受 hashline patch(LINE#HASH 锚),旧 oldText/newText 明确拒绝
6
- * (不静默降级)。renderer 自动继承内置渲染。
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 { clearSnapshots, getState } from "./pi/state.ts";
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 = 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
- * 配置加载:项目 `.pi/settings.json` 替换全局,per-field `??` DEFAULT 兜底。
3
- * 配置字段 `hashlineEdit`(去 `pi-` 前缀转 camelCase)。
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
- /** 是否启用 hashlinefalse 时透传内置 read/edit)。 */
14
+ /** Whether hashline is enabled (when false, delegate to the built-in read/edit). */
14
15
  enabled: boolean;
15
- /** hash 长度(默认 4)。 */
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
- /** 直接 JSON.parse,不剥注释(标准 JSON 禁止注释,出错降级默认)。 */
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
- * 加载配置。项目 `cwd/.pi/settings.json` 的 `hashlineEdit` 整块替换全局,
39
- * 缺失字段由 DEFAULT_CONFIG 兜底。
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"));