pi-hashline-edit-pro 4.2.5 → 4.2.6
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 +2 -1
- package/package.json +1 -1
- package/src/hash-store.ts +75 -4
- package/src/hashline/resolve.ts +2 -2
- package/src/insert.ts +4 -10
- package/src/replace.ts +2 -8
- package/src/utils.ts +94 -60
package/README.md
CHANGED
|
@@ -218,7 +218,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
218
218
|
| Code | Meaning |
|
|
219
219
|
| --- | --- |
|
|
220
220
|
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
|
|
221
|
-
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example unwrapped JSON array syntax
|
|
221
|
+
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example unwrapped JSON array syntax, embedded newlines split into lines, or stringified array text that could not be parsed and was kept as one literal line). |
|
|
222
222
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-char anchor. |
|
|
223
223
|
| `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
|
|
224
224
|
| `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
|
|
@@ -236,6 +236,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
236
236
|
| `[E_BOUNDARY_STRICT]` | Strict boundary dedup rejected the edit because replacement lines re-include edge lines; resend without those lines. |
|
|
237
237
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
238
238
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
|
239
|
+
| `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
|
|
239
240
|
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
240
241
|
| `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
|
|
241
242
|
| `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. Retry with disjoint ranges. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
package/src/hash-store.ts
CHANGED
|
@@ -39,10 +39,81 @@ interface RawDb {
|
|
|
39
39
|
close(): void;
|
|
40
40
|
readonly isOpen: boolean;
|
|
41
41
|
}
|
|
42
|
-
export type SqliteEngine = "node:sqlite";
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
export type SqliteEngine = "node:sqlite" | "bun:sqlite";
|
|
43
|
+
|
|
44
|
+
interface BunStatementLike {
|
|
45
|
+
get(...params: SqlParams): unknown;
|
|
46
|
+
all(...params: SqlParams): unknown[];
|
|
47
|
+
run(...params: SqlParams): unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface BunDbLike {
|
|
51
|
+
exec(sql: string): void;
|
|
52
|
+
prepare(sql: string): BunStatementLike;
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function wrapBunDatabase(mod: { Database: new (path: string) => BunDbLike }): (path: string) => RawDb {
|
|
57
|
+
return (path) => {
|
|
58
|
+
const db = new mod.Database(path);
|
|
59
|
+
db.exec(`PRAGMA busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
|
|
60
|
+
let closed = false;
|
|
61
|
+
return {
|
|
62
|
+
exec: (sql) => db.exec(sql),
|
|
63
|
+
prepare: (sql) => {
|
|
64
|
+
const stmt = db.prepare(sql);
|
|
65
|
+
return {
|
|
66
|
+
get: (...params) => stmt.get(...params) ?? undefined,
|
|
67
|
+
all: (...params) => stmt.all(...params),
|
|
68
|
+
run: (...params) => stmt.run(...params),
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
close: () => {
|
|
72
|
+
if (!closed) {
|
|
73
|
+
closed = true;
|
|
74
|
+
db.close();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
get isOpen() {
|
|
78
|
+
return !closed;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadNodeEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
|
|
85
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
86
|
+
return {
|
|
87
|
+
engine: "node:sqlite",
|
|
88
|
+
open: (path) => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function loadBunEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
|
|
93
|
+
const specifier = "bun:sqlite";
|
|
94
|
+
const mod = await import(specifier) as { Database: new (path: string) => BunDbLike };
|
|
95
|
+
return { engine: "bun:sqlite", open: wrapBunDatabase(mod) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isBunRuntime = typeof process !== "undefined" && typeof (process.versions as Record<string, string | undefined>).bun === "string";
|
|
99
|
+
|
|
100
|
+
async function selectSqliteEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
|
|
101
|
+
const candidates = isBunRuntime ? [loadBunEngine, loadNodeEngine] : [loadNodeEngine, loadBunEngine];
|
|
102
|
+
let lastError: unknown;
|
|
103
|
+
for (const candidate of candidates) {
|
|
104
|
+
try {
|
|
105
|
+
return await candidate();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
lastError = error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
111
|
+
throw new Error(`[E_STORE_UNAVAILABLE] No SQLite runtime available (node:sqlite and bun:sqlite both failed to load): ${detail}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const selectedEngine = await selectSqliteEngine();
|
|
115
|
+
const sqliteEngine: SqliteEngine = selectedEngine.engine;
|
|
116
|
+
const openDbFn = selectedEngine.open;
|
|
46
117
|
|
|
47
118
|
interface Prepared {
|
|
48
119
|
get: (...params: SqlParams) => Record<string, unknown> | undefined;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
|
|
1
|
+
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached, decodeStringArray } from "../utils";
|
|
2
2
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
3
3
|
import { HASH_SEP, stripRowPrefix, canon } from "./hash";
|
|
4
4
|
import { HASH_RUN } from "./alphabet";
|
|
@@ -171,7 +171,7 @@ export function stripAnchorRow(
|
|
|
171
171
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
172
172
|
assertItem(edit as Record<string, unknown>);
|
|
173
173
|
|
|
174
|
-
const replaceLines = parseText(edit.replacement_lines, warnings);
|
|
174
|
+
const replaceLines = parseText(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines, warnings);
|
|
175
175
|
const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
|
|
176
176
|
return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
|
|
177
177
|
}) as [string, string];
|
package/src/insert.ts
CHANGED
|
@@ -95,11 +95,8 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
|
|
|
95
95
|
const normalized = normReq(request);
|
|
96
96
|
const previewFixes: string[] = [];
|
|
97
97
|
if (isRec(normalized)) {
|
|
98
|
-
const expanded = decodeStringArray(normalized.lines);
|
|
99
|
-
if (expanded)
|
|
100
|
-
previewFixes.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
|
|
101
|
-
normalized.lines = expanded;
|
|
102
|
-
}
|
|
98
|
+
const expanded = decodeStringArray(normalized.lines, previewFixes, "lines");
|
|
99
|
+
if (expanded) normalized.lines = expanded;
|
|
103
100
|
}
|
|
104
101
|
assertInsertReq(normalized);
|
|
105
102
|
const previewReq = normalized as InsertReq;
|
|
@@ -178,11 +175,8 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
178
175
|
const canonical = normReq(params);
|
|
179
176
|
const insertWarnings: string[] = [];
|
|
180
177
|
if (isRec(canonical)) {
|
|
181
|
-
const expanded = decodeStringArray(canonical.lines);
|
|
182
|
-
if (expanded)
|
|
183
|
-
insertWarnings.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
|
|
184
|
-
canonical.lines = expanded;
|
|
185
|
-
}
|
|
178
|
+
const expanded = decodeStringArray(canonical.lines, insertWarnings, "lines");
|
|
179
|
+
if (expanded) canonical.lines = expanded;
|
|
186
180
|
}
|
|
187
181
|
assertInsertReq(canonical);
|
|
188
182
|
const req = canonical;
|
package/src/replace.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./replace-diff";
|
|
12
12
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
13
13
|
import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
14
|
-
import {
|
|
14
|
+
import { splitLines } from "./utils";
|
|
15
15
|
import { loadP, loadGuide } from "./prompts";
|
|
16
16
|
import { type FileIdentity } from "./fs-write";
|
|
17
17
|
import { applyEdit,
|
|
@@ -118,17 +118,11 @@ function countLineChanges(
|
|
|
118
118
|
|
|
119
119
|
export function buildReplaceHEdit(params: ReqParams): { edit: HEdit; warnings: string[] } {
|
|
120
120
|
const editWarnings: string[] = [];
|
|
121
|
-
let replacementLines = params.replacement_lines;
|
|
122
|
-
const expandedReplacement = decodeStringArray(replacementLines);
|
|
123
|
-
if (expandedReplacement) {
|
|
124
|
-
editWarnings.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.');
|
|
125
|
-
replacementLines = expandedReplacement;
|
|
126
|
-
}
|
|
127
121
|
const edit = resEdit(
|
|
128
122
|
{
|
|
129
123
|
remove_from: params.remove_from,
|
|
130
124
|
remove_to: params.remove_to,
|
|
131
|
-
replacement_lines:
|
|
125
|
+
replacement_lines: params.replacement_lines,
|
|
132
126
|
},
|
|
133
127
|
editWarnings,
|
|
134
128
|
);
|
package/src/utils.ts
CHANGED
|
@@ -181,83 +181,117 @@ function formatLineLimit(displayPath: string, limit: number, count: number | und
|
|
|
181
181
|
return `[E_FILE_TOO_LARGE] ${displayPath} ${detail} lines, exceeding the ${limit}-line hashline limit. For very large files, use write.`;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (value === "\f") return "\\f";
|
|
189
|
-
if (value === "\r") return "\\r";
|
|
190
|
-
const hex = value.charCodeAt(0).toString(16).padStart(4, "0");
|
|
191
|
-
return `\\u${hex}`;
|
|
184
|
+
function stripCodeFence(text: string): string {
|
|
185
|
+
const trimmed = text.trim();
|
|
186
|
+
const fenced = /^```[^\n]*\n([\s\S]*?)\n?```$/.exec(trimmed);
|
|
187
|
+
return fenced ? fenced[1]!.trim() : trimmed;
|
|
192
188
|
}
|
|
193
189
|
|
|
194
|
-
function
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
190
|
+
function jsonStringArray(text: string): string[] | undefined {
|
|
191
|
+
try {
|
|
192
|
+
const parsed: unknown = JSON.parse(text);
|
|
193
|
+
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")) {
|
|
194
|
+
return parsed as string[];
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
198
197
|
}
|
|
199
|
-
return
|
|
198
|
+
return undefined;
|
|
200
199
|
}
|
|
201
200
|
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return
|
|
201
|
+
function decodeEscape(char: string): string | undefined {
|
|
202
|
+
switch (char) {
|
|
203
|
+
case "\"": return "\"";
|
|
204
|
+
case "'": return "'";
|
|
205
|
+
case "\\": return "\\";
|
|
206
|
+
case "/": return "/";
|
|
207
|
+
case "b": return "\b";
|
|
208
|
+
case "f": return "\f";
|
|
209
|
+
case "n": return "\n";
|
|
210
|
+
case "r": return "\r";
|
|
211
|
+
case "t": return "\t";
|
|
212
|
+
default: return undefined;
|
|
211
213
|
}
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
function
|
|
215
|
-
const
|
|
216
|
-
let
|
|
217
|
-
let
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
if (escaped)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
current = "";
|
|
216
|
+
function scanQuotedSegment(inner: string, start: number): { value: string; next: number } | undefined {
|
|
217
|
+
const quote = inner[start]!;
|
|
218
|
+
let out = "";
|
|
219
|
+
let index = start + 1;
|
|
220
|
+
while (index < inner.length) {
|
|
221
|
+
const char = inner[index]!;
|
|
222
|
+
if (char === "\\") {
|
|
223
|
+
const escaped = inner[index + 1];
|
|
224
|
+
if (escaped === undefined) return undefined;
|
|
225
|
+
if (escaped === "u") {
|
|
226
|
+
const hex = inner.slice(index + 2, index + 6);
|
|
227
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined;
|
|
228
|
+
out += String.fromCharCode(Number.parseInt(hex, 16));
|
|
229
|
+
index += 6;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const decoded = decodeEscape(escaped);
|
|
233
|
+
if (decoded === undefined) return undefined;
|
|
234
|
+
out += decoded;
|
|
235
|
+
index += 2;
|
|
235
236
|
continue;
|
|
236
237
|
}
|
|
237
|
-
|
|
238
|
+
if (char === quote) return { value: out, next: index + 1 };
|
|
239
|
+
out += char;
|
|
240
|
+
index += 1;
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function scanArrayText(inner: string): string[] | undefined {
|
|
246
|
+
const values: string[] = [];
|
|
247
|
+
let index = 0;
|
|
248
|
+
while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
|
|
249
|
+
if (index >= inner.length) return [];
|
|
250
|
+
for (;;) {
|
|
251
|
+
if (inner[index] !== "\"" && inner[index] !== "'") return undefined;
|
|
252
|
+
const segment = scanQuotedSegment(inner, index);
|
|
253
|
+
if (!segment) return undefined;
|
|
254
|
+
values.push(segment.value);
|
|
255
|
+
index = segment.next;
|
|
256
|
+
while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
|
|
257
|
+
if (index >= inner.length) return values;
|
|
258
|
+
if (inner[index] !== ",") return undefined;
|
|
259
|
+
index += 1;
|
|
260
|
+
while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
|
|
261
|
+
if (index >= inner.length) return values;
|
|
238
262
|
}
|
|
239
|
-
if (inQuotes || escaped) return undefined;
|
|
240
|
-
segments.push(current);
|
|
241
|
-
return segments;
|
|
242
263
|
}
|
|
243
264
|
|
|
244
265
|
function decodeArrayText(value: unknown): string[] | undefined {
|
|
245
266
|
if (typeof value !== "string") return undefined;
|
|
246
|
-
const trimmed = value
|
|
267
|
+
const trimmed = stripCodeFence(value);
|
|
247
268
|
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
269
|
+
const decoded = jsonStringArray(trimmed);
|
|
270
|
+
if (decoded !== undefined && decoded.length > 0) return decoded;
|
|
271
|
+
const scanned = scanArrayText(trimmed.slice(1, -1));
|
|
272
|
+
return scanned !== undefined && scanned.length > 0 ? scanned : undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function looksLikeStringArray(value: unknown): boolean {
|
|
276
|
+
if (typeof value !== "string") return false;
|
|
277
|
+
const trimmed = stripCodeFence(value);
|
|
278
|
+
return trimmed.endsWith("]") && /^\[\s*['"]/.test(trimmed);
|
|
257
279
|
}
|
|
258
280
|
|
|
259
|
-
export function decodeStringArray(value: unknown): string[] | undefined {
|
|
260
|
-
|
|
261
|
-
|
|
281
|
+
export function decodeStringArray(value: unknown, warnings?: string[], label = "replacement_lines"): string[] | undefined {
|
|
282
|
+
const candidate = typeof value === "string"
|
|
283
|
+
? value
|
|
284
|
+
: Array.isArray(value) && value.length === 1 && typeof value[0] === "string"
|
|
285
|
+
? value[0]
|
|
286
|
+
: undefined;
|
|
287
|
+
if (candidate === undefined) return undefined;
|
|
288
|
+
const decoded = decodeArrayText(candidate);
|
|
289
|
+
if (decoded !== undefined) {
|
|
290
|
+
warnings?.push(`[W_BAD_SHAPE] Unwrapped JSON array syntax from a ${label} element.`);
|
|
291
|
+
return decoded;
|
|
292
|
+
}
|
|
293
|
+
if (looksLikeStringArray(candidate)) {
|
|
294
|
+
warnings?.push(`[W_BAD_SHAPE] ${label} looked like a JSON array but could not be parsed; kept as one literal line: ${clipLine(candidate, 60)}`);
|
|
295
|
+
}
|
|
262
296
|
return undefined;
|
|
263
297
|
}
|