@d3ara1n/pi-hashline-edit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parsePatch } from "./parse.ts";
4
+
5
+ test("解析 replace 单行", () => {
6
+ const r = parsePatch("file: f.ts\n\nreplace 4#ABCD:\n+new line\n");
7
+ assert.equal(r.ok, true);
8
+ if (r.ok) {
9
+ assert.equal(r.patch.path, "f.ts");
10
+ assert.equal(r.patch.edits.length, 1);
11
+ assert.equal(r.patch.edits[0].op, "replace");
12
+ }
13
+ });
14
+
15
+ test("解析 replace range", () => {
16
+ const r = parsePatch("file: f.ts\nreplace 3#AAA..5#BBB:\n+a\n+b\n");
17
+ assert.equal(r.ok, true);
18
+ if (r.ok) {
19
+ const e = r.patch.edits[0];
20
+ if (e.op === "replace") {
21
+ assert.equal(e.start.line, 3);
22
+ assert.equal(e.end?.line, 5);
23
+ assert.deepEqual(e.body, ["a", "b"]);
24
+ }
25
+ }
26
+ });
27
+
28
+ test("解析 delete(无 body)", () => {
29
+ const r = parsePatch("file: f.ts\ndelete 2#XYZ\n");
30
+ assert.equal(r.ok, true);
31
+ if (r.ok) assert.equal(r.patch.edits[0].op, "delete");
32
+ });
33
+
34
+ test("解析 insert_after / insert_before", () => {
35
+ const r = parsePatch("file: f.ts\ninsert_after 3#ABC:\n+x\n\ninsert_before 5#DEF:\n+y\n");
36
+ assert.equal(r.ok, true);
37
+ if (r.ok) assert.equal(r.patch.edits.length, 2);
38
+ });
39
+
40
+ test("解析 append / prepend", () => {
41
+ const r = parsePatch("file: f.ts\nappend:\n+z\n\nprepend:\n+w\n");
42
+ assert.equal(r.ok, true);
43
+ if (r.ok) {
44
+ assert.equal(r.patch.edits[0].op, "append");
45
+ assert.equal(r.patch.edits[1].op, "prepend");
46
+ }
47
+ });
48
+
49
+ test("多操作混合", () => {
50
+ const r = parsePatch("file: f.ts\n\nreplace 1#A:\n+x\n\ndelete 3#C\n\ninsert_after 5#E:\n+y\n");
51
+ assert.equal(r.ok, true);
52
+ if (r.ok) assert.equal(r.patch.edits.length, 3);
53
+ });
54
+
55
+ test("body 保留字面(含 + 前缀、markdown)", () => {
56
+ const r = parsePatch("file: f.ts\nreplace 1#A:\n++i\n+- item\n+\n");
57
+ assert.equal(r.ok, true);
58
+ if (r.ok) {
59
+ const e = r.patch.edits[0];
60
+ if (e.op === "replace") assert.deepEqual(e.body, ["+i", "- item", ""]);
61
+ }
62
+ });
63
+
64
+ test("冒号可选", () => {
65
+ const r = parsePatch("file: f.ts\nreplace 1#A\n+x\n");
66
+ assert.equal(r.ok, true);
67
+ });
68
+
69
+ test("CRLF 归一", () => {
70
+ const r = parsePatch("file: f.ts\r\nreplace 1#A:\r\n+x\r\n");
71
+ assert.equal(r.ok, true);
72
+ if (r.ok) {
73
+ const e = r.patch.edits[0];
74
+ if (e.op === "replace") assert.deepEqual(e.body, ["x"]); // 无 \r
75
+ }
76
+ });
77
+
78
+ // —— 错误路径 ——
79
+
80
+ test("缺 file 头报错", () => {
81
+ const r = parsePatch("replace 1#A:\n+x\n");
82
+ assert.equal(r.ok, false);
83
+ });
84
+
85
+ test("空输入报错", () => {
86
+ assert.equal(parsePatch("").ok, false);
87
+ assert.equal(parsePatch("\n\n").ok, false);
88
+ });
89
+
90
+ test("空 body 报错", () => {
91
+ const r = parsePatch("file: f.ts\nreplace 1#A:\n");
92
+ assert.equal(r.ok, false);
93
+ if (!r.ok) assert.equal(r.error.kind, "parse");
94
+ });
95
+
96
+ test("未知 verb 报错(拒绝 SWAP/DEL)", () => {
97
+ const r = parsePatch("file: f.ts\nSWAP 1#A:\n+x\n");
98
+ assert.equal(r.ok, false);
99
+ if (!r.ok) assert.equal(r.error.kind, "parse");
100
+ });
101
+
102
+ test("stray body 报错", () => {
103
+ const r = parsePatch("file: f.ts\n+x\n");
104
+ assert.equal(r.ok, false);
105
+ });
106
+
107
+ test("delete 带 body 报错", () => {
108
+ const r = parsePatch("file: f.ts\ndelete 2#X\n+leak\n");
109
+ // delete 后紧跟 + 行 → 下一轮判为 stray body
110
+ assert.equal(r.ok, false);
111
+ });
112
+
113
+ test("错误含输入行号", () => {
114
+ const r = parsePatch("file: f.ts\n\nreplace 1#A:\n+x\n\nSWAP 2#B:\n+y\n");
115
+ assert.equal(r.ok, false);
116
+ if (!r.ok) assert.ok(r.error.line && r.error.line >= 5);
117
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 严格解析器:patch 字符串 → {@link ParsedPatch}。
3
+ *
4
+ * 核心零猜测:只接受规范格式(正确 verb + 正确锚 + `+` body 行),
5
+ * 任何变体(裸行、旧 `oldText`/`newText`、`SWAP`/`DEL` 等)一律拒绝,
6
+ * 交给 `transforms/normalize-legacy` 中间件归一化后再进入解析。
7
+ *
8
+ * 格式:
9
+ *
10
+ * ```
11
+ * file: <path>
12
+ *
13
+ * replace <line>#<hash>[..<line>#<hash>]:
14
+ * +<text>
15
+ *
16
+ * delete <line>#<hash>[..<line>#<hash>]
17
+ *
18
+ * insert_after <line>#<hash>:
19
+ * +<text>
20
+ *
21
+ * append:
22
+ * +<text>
23
+ * ```
24
+ *
25
+ * @module pi-hashline-edit/core
26
+ */
27
+
28
+ import type { Anchor, Edit, ParsedPatch, PatchError } from "./types.ts";
29
+
30
+ export type ParseResult =
31
+ | { readonly ok: true; readonly patch: ParsedPatch }
32
+ | { readonly ok: false; readonly error: PatchError };
33
+
34
+ function parseError(message: string, line?: number): PatchError {
35
+ return { kind: "parse", message, line };
36
+ }
37
+
38
+ const ANCHOR_RE = /^(\d+)#([0-9A-Za-z]+)$/;
39
+
40
+ function parseAnchorStr(s: string): Anchor | null {
41
+ const m = ANCHOR_RE.exec(s);
42
+ return m ? { line: Number(m[1]), hash: m[2] } : null;
43
+ }
44
+
45
+ function parseRangeStr(s: string): { start: Anchor; end?: Anchor } | null {
46
+ const idx = s.indexOf("..");
47
+ if (idx === -1) {
48
+ const a = parseAnchorStr(s);
49
+ return a ? { start: a } : null;
50
+ }
51
+ const a = parseAnchorStr(s.slice(0, idx));
52
+ const b = parseAnchorStr(s.slice(idx + 2));
53
+ if (!a || !b) return null;
54
+ return { start: a, end: b };
55
+ }
56
+
57
+ type ParsedHeader =
58
+ | { readonly error: string }
59
+ | {
60
+ readonly verb: string;
61
+ readonly hasBody: boolean;
62
+ readonly build: (body: string[]) => Edit;
63
+ };
64
+
65
+ /** 解析单个操作头(已 trim)。末尾冒号可选(有 body 的 verb)。 */
66
+ function parseOpHeader(s: string): ParsedHeader {
67
+ let core = s;
68
+ if (core.endsWith(":")) core = core.slice(0, -1).trimEnd();
69
+
70
+ const sp = core.indexOf(" ");
71
+ const verb = sp === -1 ? core : core.slice(0, sp);
72
+ const rest = sp === -1 ? "" : core.slice(sp + 1).trim();
73
+
74
+ switch (verb) {
75
+ case "replace": {
76
+ const r = parseRangeStr(rest);
77
+ if (!r) return { error: `replace needs "<line>#<hash>[..<line>#<hash>]", got: "${s}"` };
78
+ return { verb, hasBody: true, build: (body) => ({ op: "replace", start: r.start, end: r.end, body }) };
79
+ }
80
+ case "delete": {
81
+ const r = parseRangeStr(rest);
82
+ if (!r) return { error: `delete needs "<line>#<hash>[..<line>#<hash>]", got: "${s}"` };
83
+ return { verb, hasBody: false, build: () => ({ op: "delete", start: r.start, end: r.end }) };
84
+ }
85
+ case "insert_after":
86
+ case "insert_before": {
87
+ const a = parseAnchorStr(rest);
88
+ if (!a) return { error: `${verb} needs "<line>#<hash>", got: "${s}"` };
89
+ return { verb, hasBody: true, build: (body) => ({ op: verb, anchor: a, body }) };
90
+ }
91
+ case "append":
92
+ case "prepend": {
93
+ if (rest !== "") return { error: `${verb} takes no anchor, got: "${s}"` };
94
+ return { verb, hasBody: true, build: (body) => ({ op: verb, body }) };
95
+ }
96
+ default:
97
+ return {
98
+ error: `unknown verb "${verb}". Use replace / delete / insert_after / insert_before / append / prepend`,
99
+ };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 严格解析 patch。
105
+ *
106
+ * @param input patch 字符串(CRLF 自动归一为 LF)
107
+ * @returns 解析结果;非法格式返回 `ok: false` + PatchError(含输入行号)
108
+ */
109
+ export function parsePatch(input: string): ParseResult {
110
+ const normalized = input.replace(/\r\n/g, "\n");
111
+ const lines = normalized.split("\n");
112
+ const n = lines.length;
113
+ let i = 0;
114
+
115
+ while (i < n && lines[i].trim() === "") i++;
116
+ if (i >= n) return { ok: false, error: parseError("empty input", 1) };
117
+
118
+ const fileMatch = /^file:\s*(.+?)\s*$/i.exec(lines[i]);
119
+ if (!fileMatch) {
120
+ return {
121
+ ok: false,
122
+ error: parseError(`expected "file: <path>" on first non-blank line, got: "${lines[i]}"`, i + 1),
123
+ };
124
+ }
125
+ const path = fileMatch[1];
126
+ i++;
127
+
128
+ const edits: Edit[] = [];
129
+ while (i < n) {
130
+ while (i < n && lines[i].trim() === "") i++;
131
+ if (i >= n) break;
132
+
133
+ const headerLineNo = i + 1;
134
+ const raw = lines[i];
135
+ const trimmed = raw.trim();
136
+
137
+ if (trimmed.startsWith("+")) {
138
+ return { ok: false, error: parseError(`stray body row has no preceding header: "${raw}"`, headerLineNo) };
139
+ }
140
+
141
+ const parsed = parseOpHeader(trimmed);
142
+ if ("error" in parsed) {
143
+ return { ok: false, error: parseError(parsed.error, headerLineNo) };
144
+ }
145
+ i++;
146
+
147
+ let body: string[] = [];
148
+ if (parsed.hasBody) {
149
+ while (i < n && lines[i].startsWith("+")) {
150
+ body.push(lines[i].slice(1));
151
+ i++;
152
+ }
153
+ if (body.length === 0) {
154
+ return {
155
+ ok: false,
156
+ error: parseError(`"${parsed.verb}" needs at least one "+TEXT" body row`, headerLineNo),
157
+ };
158
+ }
159
+ }
160
+
161
+ edits.push(parsed.build(body));
162
+ }
163
+
164
+ return { ok: true, patch: { path, edits } };
165
+ }
@@ -0,0 +1,60 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { splitLines, joinLines, createSnapshot, verifyAnchor } from "./snapshot.ts";
4
+
5
+ test("splitLines 边界", () => {
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 去掉 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 行尾恢复", () => {
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("createSnapshot 记录 path/text/hashLen", () => {
27
+ const s = createSnapshot("f.ts", "a\nb\nc\n");
28
+ assert.equal(s.path, "f.ts");
29
+ assert.equal(s.text, "a\nb\nc\n");
30
+ assert.equal(s.lineHashes.length, 3);
31
+ assert.equal(s.hashLen, 4);
32
+ });
33
+
34
+ test("createSnapshot 自定义 hashLen", () => {
35
+ assert.equal(createSnapshot("f", "a\n", 6).hashLen, 6);
36
+ });
37
+
38
+ test("createSnapshot 记录 lineEnding", () => {
39
+ assert.equal(createSnapshot("f", "a\nb\n").lineEnding, "lf");
40
+ assert.equal(createSnapshot("f", "a\r\nb\r\n").lineEnding, "crlf");
41
+ });
42
+
43
+ test("verifyAnchor 匹配", () => {
44
+ const s = createSnapshot("f", "a\nb\n");
45
+ assert.deepEqual(verifyAnchor(s, { line: 2, hash: s.lineHashes[1] }), { ok: true, line: 2 });
46
+ });
47
+
48
+ test("verifyAnchor hash_not_found", () => {
49
+ const s = createSnapshot("f", "a\nb\n");
50
+ const r = verifyAnchor(s, { line: 1, hash: "ZZZZ" });
51
+ assert.equal(r.ok, false);
52
+ if (!r.ok) assert.equal(r.error, "hash_not_found");
53
+ });
54
+
55
+ test("verifyAnchor line_mismatch(漂移)", () => {
56
+ const s = createSnapshot("f", "a\nb\n");
57
+ const r = verifyAnchor(s, { line: 1, hash: s.lineHashes[1] });
58
+ assert.equal(r.ok, false);
59
+ if (!r.ok) assert.equal(r.error, "line_mismatch");
60
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 文件快照与锚校验。
3
+ *
4
+ * 快照在 read 时记录原文 + 每行 hash;apply 时校验「当前文件 == 快照」
5
+ * (stale 检查)以及每个锚的 hash 与行号对得上(防模型记错)。
6
+ *
7
+ * CRLF:splitLines 归一化去掉每行尾的 \r(hash 基于干净行,与模型从显示中
8
+ * 复制的无 \r 内容一致),createSnapshot 记录原行尾,joinLines 按记录的
9
+ * 行尾恢复——保证 CRLF 文件 edit 后行尾不变。
10
+ *
11
+ * @module pi-hashline-edit/core
12
+ */
13
+
14
+ import { hashFileLines } from "./hash.ts";
15
+ import type { Anchor, FileSnapshot, LineEnding } from "./types.ts";
16
+
17
+ /**
18
+ * 按行分割文本,去掉每行尾的 \r(CRLF 归一化,hash 基于干净行)。
19
+ *
20
+ * 约定:末尾换行视为最后一行的终止符,不产生多余空尾行。
21
+ * - `"a\nb\n"` → `["a", "b"]`
22
+ * - `"a\r\nb\r\n"` → `["a", "b"]`(\r 去掉)
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
+ /** 检测文本的主导行尾(含 \r\n 即视为 CRLF)。 */
33
+ export function detectLineEnding(text: string): LineEnding {
34
+ return text.includes("\r\n") ? "crlf" : "lf";
35
+ }
36
+
37
+ /** 把行数组 join 成文本,按指定行尾恢复(默认 LF)。非空文件末尾带换行。 */
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
+ }
43
+
44
+ /** 为文件创建快照:记录原文 + 行尾 + 每行 context-aware hash。 */
45
+ export function createSnapshot(path: string, text: string, len = 4): FileSnapshot {
46
+ const lines = splitLines(text);
47
+ const lineHashes = hashFileLines(lines, len);
48
+ return { path, lineHashes, text, hashLen: len, lineEnding: detectLineEnding(text) };
49
+ }
50
+
51
+ /** 锚校验结果。 */
52
+ export type AnchorVerifyResult =
53
+ | { readonly ok: true; readonly line: number }
54
+ | {
55
+ readonly ok: false;
56
+ readonly error: "hash_not_found" | "line_mismatch" | "collision";
57
+ /** hash 实际出现的行号(1-based)。 */
58
+ readonly found?: readonly number[];
59
+ };
60
+
61
+ /**
62
+ * 在快照中校验锚。
63
+ *
64
+ * - hash 唯一存在且行号匹配 → `ok`
65
+ * - hash 唯一存在但行号不符 → `line_mismatch`(`found` 给真实行号;漂移,可由 relocate 中间件处理)
66
+ * - hash 多处出现 → `collision`(`found` 给所有位置)
67
+ * - hash 不存在 → `hash_not_found`(文件已变,需重读)
68
+ */
69
+ export function verifyAnchor(snapshot: FileSnapshot, anchor: Anchor): AnchorVerifyResult {
70
+ const found: number[] = [];
71
+ for (let i = 0; i < snapshot.lineHashes.length; i++) {
72
+ if (snapshot.lineHashes[i] === anchor.hash) found.push(i + 1);
73
+ }
74
+ if (found.length === 0) return { ok: false, error: "hash_not_found" };
75
+ if (found.length > 1) return { ok: false, error: "collision", found };
76
+ if (found[0] !== anchor.line) return { ok: false, error: "line_mismatch", found };
77
+ return { ok: true, line: anchor.line };
78
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Hashline 核心类型定义。
3
+ *
4
+ * @module pi-hashline-edit/core
5
+ */
6
+
7
+ /** 行锚:行号(1-based)+ 内容 hash 双重引用。 */
8
+ export interface Anchor {
9
+ readonly line: number;
10
+ readonly hash: string;
11
+ }
12
+
13
+ /**
14
+ * 编辑操作。所有带行号的操作都通过 {@link Anchor} 引用——
15
+ * 行号给人读,hash 给机器校验,二者必须同时匹配快照。
16
+ */
17
+ export type Edit =
18
+ | { readonly op: "replace"; readonly start: Anchor; readonly end?: Anchor; readonly body: string[] }
19
+ | { readonly op: "delete"; readonly start: Anchor; readonly end?: Anchor }
20
+ | { readonly op: "insert_after"; readonly anchor: Anchor; readonly body: string[] }
21
+ | { readonly op: "insert_before"; readonly anchor: Anchor; readonly body: string[] }
22
+ | { readonly op: "append"; readonly body: string[] }
23
+ | { readonly op: "prepend"; readonly body: string[] };
24
+
25
+ export type LineEnding = "lf" | "crlf";
26
+
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
+ /** 错误种类。 */
46
+ export type PatchErrorKind =
47
+ | "parse" // 输入格式错误
48
+ | "stale" // 文件已变(当前 text !== snapshot.text)
49
+ | "anchor" // 锚 hash 不匹配快照(模型记错)或行号越界
50
+ | "collision" // hash 在文件中多处出现,无法唯一定位
51
+ | "range" // 操作范围非法(重叠、逆序、跨空等)
52
+ | "noop"; // 编辑未产生变化(body 与目标行字节相同)
53
+
54
+ export interface PatchError {
55
+ readonly kind: PatchErrorKind;
56
+ readonly message: string;
57
+ /** 输入 patch 中的行号(1-based),用于错误定位。 */
58
+ readonly line?: number;
59
+ }
60
+
61
+ /** 应用结果。 */
62
+ export type ApplyResult =
63
+ | {
64
+ readonly ok: true;
65
+ readonly text: string;
66
+ readonly newSnapshot: FileSnapshot;
67
+ readonly changed: boolean;
68
+ readonly diff: string;
69
+ }
70
+ | { readonly ok: false; readonly error: PatchError };
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * pi-hashline-edit 扩展入口。
3
+ *
4
+ * override 内置 read/edit:read 输出「行号#hash│内容」并记录快照,
5
+ * edit 只接受 hashline patch(LINE#HASH 锚),旧 oldText/newText 明确拒绝
6
+ * (不静默降级)。renderer 自动继承内置渲染。
7
+ *
8
+ * @module pi-hashline-edit
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { loadConfig } from "./pi/config.ts";
13
+ import { clearSnapshots, getState } from "./pi/state.ts";
14
+ import { makeEditOverride } from "./pi/edit-tool.ts";
15
+ import { makeReadOverride } from "./pi/read-tool.ts";
16
+
17
+ export default function (pi: ExtensionAPI) {
18
+ const cwd = process.cwd();
19
+
20
+ // session 启动/重载时刷新配置与快照
21
+ pi.on("session_start", async () => {
22
+ const config = loadConfig(cwd);
23
+ const state = getState();
24
+ state.config = config;
25
+ state.hashLen = config.hashLen;
26
+ clearSnapshots();
27
+ });
28
+
29
+ pi.registerTool(makeReadOverride(cwd));
30
+ pi.registerTool(makeEditOverride(cwd));
31
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 配置加载:项目 `.pi/settings.json` 替换全局,per-field `??` DEFAULT 兜底。
3
+ * 配置字段 `hashlineEdit`(去 `pi-` 前缀转 camelCase)。
4
+ *
5
+ * @module pi-hashline-edit/pi
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+
12
+ export interface HashlineEditConfig {
13
+ /** 是否启用 hashline(false 时透传内置 read/edit)。 */
14
+ enabled: boolean;
15
+ /** 行 hash 长度(默认 4)。 */
16
+ hashLen: number;
17
+ }
18
+
19
+ const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
20
+
21
+ function getAgentDir(): string {
22
+ const envDir = process.env.PI_AGENT_DIR;
23
+ if (envDir) return envDir;
24
+ return path.join(os.homedir(), ".pi", "agent");
25
+ }
26
+
27
+ /** 直接 JSON.parse,不剥注释(标准 JSON 禁止注释,出错降级默认)。 */
28
+ function readSettings(filePath: string): Record<string, unknown> {
29
+ try {
30
+ if (!fs.existsSync(filePath)) return {};
31
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, unknown>;
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 加载配置。项目 `cwd/.pi/settings.json` 的 `hashlineEdit` 整块替换全局,
39
+ * 缺失字段由 DEFAULT_CONFIG 兜底。
40
+ */
41
+ export function loadConfig(cwd?: string): HashlineEditConfig {
42
+ const globalSettings = readSettings(path.join(getAgentDir(), "settings.json"));
43
+ const projectSettings = cwd ? readSettings(path.join(cwd, ".pi", "settings.json")) : {};
44
+ const raw = (projectSettings.hashlineEdit ?? globalSettings.hashlineEdit ?? {}) as Record<string, unknown>;
45
+ return {
46
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULT_CONFIG.enabled,
47
+ hashLen:
48
+ typeof raw.hashLen === "number" && raw.hashLen >= 2 && raw.hashLen <= 8
49
+ ? raw.hashLen
50
+ : DEFAULT_CONFIG.hashLen,
51
+ };
52
+ }