pi-hashline-edit-pro 1.1.2 → 1.2.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 +4 -3
- package/index.ts +2 -0
- package/package.json +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/replace-guidelines.md +1 -0
- package/prompts/undo-last-replace-guidelines.md +2 -1
- package/src/constants.ts +1 -1
- package/src/hash-store.ts +4 -0
- package/src/hashline/hash.ts +11 -41
- package/src/hashline/index.ts +1 -0
- package/src/hashline/resolve.ts +5 -1
- package/src/read.ts +4 -2
- package/src/replace.ts +5 -5
package/README.md
CHANGED
|
@@ -121,7 +121,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
121
121
|
Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so the model gets immediate `HASH│content` anchors without a separate `read` call.
|
|
122
122
|
|
|
123
123
|
- A no-op `replace` produces no diff — the file is unchanged, so existing anchors remain valid.
|
|
124
|
-
- After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — call `read`
|
|
124
|
+
- After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — the diff rows themselves are the fresh anchors (`+HASH│` and ` HASH│` rows carry the current hashes, and unchanged lines keep their previous hashes), so follow-up edits can anchor on the diff directly; call `read` when you want the full file's anchors.
|
|
125
125
|
- With auto-read disabled, `replace` / `undo_last_replace` results keep the plain summary in the model-visible text — no diff and no anchor block reach the model (the post-edit diff is still shown to the user).
|
|
126
126
|
- After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
|
|
127
127
|
- Auto-read keeps a 50KB display budget: lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
|
|
@@ -179,7 +179,7 @@ Each line is canonicalized (carriage returns stripped, trailing whitespace trimm
|
|
|
179
179
|
|
|
180
180
|
The alphabet is sized for an LLM consumer: the model tokenizes rather than squinting at glyphs, so case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded — a hash starting with `-` is shape-identical to a diff-preview deletion row, and `-`/`_` at a line start are markdown-active, inviting mis-copying and false autocorrections.
|
|
181
181
|
|
|
182
|
-
**Unique anchors by construction.** If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset (O(1) amortized). Every line in a file therefore gets a unique anchor — two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
182
|
+
**Unique anchors by construction.** If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset by probing with a stride coprime to the hash space (O(1) amortized). The stride is `62² + 62 + 1`, so consecutive collisions — runs of blank lines, repeated `}` — land on anchors that differ in all three characters instead of sharing a prefix. Every line in a file therefore gets a unique anchor — two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
183
183
|
|
|
184
184
|
## Design decisions
|
|
185
185
|
|
|
@@ -193,8 +193,9 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
193
193
|
|
|
194
194
|
- **Stale anchors.** `[E_STALE_ANCHOR]` / `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read, or an earlier `read` never happened. Call `read` for fresh anchors and retry.
|
|
195
195
|
- **Reset the hash store.** Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
196
|
+
- **Upgrading.** A hash-allocation change clears the hash store once on the first run after upgrade — anchors are rebuilt on the next read and undo history is lost, but no project files are touched.
|
|
196
197
|
- **Corrupt store.** If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` (plus `-wal`/`-shm` variants) and rebuilt automatically; the quarantined files can be deleted once a healthy store exists.
|
|
197
|
-
- **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted.
|
|
198
|
+
- **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted. Legacy snapshots containing duplicate hashes are skipped and rebuilt on the next read.
|
|
198
199
|
- **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
|
|
199
200
|
|
|
200
201
|
## Development
|
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { regRead, fmtReadPreview } from "./src/read";
|
|
|
7
7
|
import type { RMetrics } from "./src/replace-response";
|
|
8
8
|
import { extractWarnings } from "./src/replace-render";
|
|
9
9
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
10
|
+
import { AUTO_READ_MAX } from "./src/constants";
|
|
10
11
|
import {
|
|
11
12
|
readConfig,
|
|
12
13
|
toggleAutoRead,
|
|
@@ -81,6 +82,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
81
82
|
fileHashes,
|
|
82
83
|
absolutePath,
|
|
83
84
|
DEFAULT_MAX_BYTES,
|
|
85
|
+
AUTO_READ_MAX,
|
|
84
86
|
);
|
|
85
87
|
return {
|
|
86
88
|
content: [
|
package/package.json
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
- `read`: call before `replace` when you need fresh HASH anchors for a file.
|
|
2
|
-
- `read`: call again after any edit to that file — changed lines get new anchors.
|
|
2
|
+
- `read`: call again after any edit to that file — changed lines get new anchors (the post-edit diff rows from replace/undo also carry fresh anchors for the changed range).
|
|
@@ -4,3 +4,4 @@
|
|
|
4
4
|
- `replace`: to replace a single line, repeat its hash in both positions of hash_range_inclusive: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
|
|
5
5
|
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
6
6
|
- `replace`: content_lines entries are single lines — never embed a line break inside an entry; pass each line as its own array entry.
|
|
7
|
+
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
- `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
|
|
1
|
+
- `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
|
|
2
|
+
- `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
|
package/src/constants.ts
CHANGED
|
@@ -4,7 +4,7 @@ export const MAX_BYTES = 100 * 1024 * 1024;
|
|
|
4
4
|
export const MAX_READ_LINE_BYTES = 200 * 1024;
|
|
5
5
|
|
|
6
6
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
7
|
-
export const HASH_STORE_VERSION =
|
|
7
|
+
export const HASH_STORE_VERSION = 5;
|
|
8
8
|
export const CONTENT_LINES_NOT_STRING_MSG =
|
|
9
9
|
`[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
10
10
|
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
|
package/src/hash-store.ts
CHANGED
|
@@ -318,6 +318,10 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
|
|
|
318
318
|
const rows: [string, string, number, string, number][] = [];
|
|
319
319
|
for (const [key, value] of Object.entries(raw)) {
|
|
320
320
|
if (!isValidSnapshot(value)) continue;
|
|
321
|
+
if (new Set(value.hashes).size !== value.hashes.length) {
|
|
322
|
+
console.warn(`Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
321
325
|
rows.push([
|
|
322
326
|
key,
|
|
323
327
|
contentChecksum(value.content),
|
package/src/hashline/hash.ts
CHANGED
|
@@ -22,6 +22,8 @@ export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
|
|
|
22
22
|
export const HASH_SPACE = ALPH.length ** HASH_LEN;
|
|
23
23
|
export const MAX_HASH_LINES = HASH_SPACE;
|
|
24
24
|
|
|
25
|
+
export const HASH_PROBE_STRIDE = ALPH.length ** 2 + ALPH.length + 1;
|
|
26
|
+
|
|
25
27
|
function idxToHash(idx: number): string {
|
|
26
28
|
let out = "";
|
|
27
29
|
for (let j = 0; j < HASH_LEN; j++) {
|
|
@@ -66,44 +68,13 @@ function setBit(bits: Uint32Array, idx: number): void {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
69
|
-
const totalWords = bits.length;
|
|
70
71
|
const totalBits = HASH_SPACE;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const bitOffset = start & 31;
|
|
77
|
-
const wordBits = (w: number): number => (w === totalWords - 1 ? lastWordBits : 32);
|
|
78
|
-
|
|
79
|
-
let word = bits[wordIdx];
|
|
80
|
-
for (let b = bitOffset; b < wordBits(wordIdx); b++) {
|
|
81
|
-
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
for (let w = wordIdx + 1; w < totalWords; w++) {
|
|
85
|
-
word = bits[w];
|
|
86
|
-
if (~word !== 0) {
|
|
87
|
-
for (let b = 0; b < wordBits(w); b++) {
|
|
88
|
-
if ((word >>> b & 1) === 0) return w * 32 + b;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
72
|
+
let idx = start % totalBits;
|
|
73
|
+
for (let i = 0; i < totalBits; i++) {
|
|
74
|
+
if (!getBit(bits, idx)) return idx;
|
|
75
|
+
idx += HASH_PROBE_STRIDE;
|
|
76
|
+
if (idx >= totalBits) idx -= totalBits;
|
|
91
77
|
}
|
|
92
|
-
|
|
93
|
-
for (let w = 0; w < wordIdx; w++) {
|
|
94
|
-
word = bits[w];
|
|
95
|
-
if (~word !== 0) {
|
|
96
|
-
for (let b = 0; b < wordBits(w); b++) {
|
|
97
|
-
if ((word >>> b & 1) === 0) return w * 32 + b;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
word = bits[wordIdx];
|
|
103
|
-
for (let b = 0; b < bitOffset; b++) {
|
|
104
|
-
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
78
|
throw new Error(
|
|
108
79
|
`[E_FILE_TOO_LARGE] Cannot allocate a unique hash anchor: the file exceeds the ${HASH_SPACE}-line limit for ${HASH_LEN}-char hashline anchors. For very large files use write or a non-line-based approach.`,
|
|
109
80
|
);
|
|
@@ -112,13 +83,12 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
|
112
83
|
function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
|
|
113
84
|
if (!getBit(used, baseIdx)) {
|
|
114
85
|
setBit(used, baseIdx);
|
|
115
|
-
hint.value = baseIdx +
|
|
86
|
+
hint.value = baseIdx + HASH_PROBE_STRIDE;
|
|
116
87
|
return hashAt(baseIdx);
|
|
117
88
|
}
|
|
118
|
-
const
|
|
119
|
-
const nextIdx = nextZeroBit(used, start);
|
|
89
|
+
const nextIdx = nextZeroBit(used, hint.value);
|
|
120
90
|
setBit(used, nextIdx);
|
|
121
|
-
hint.value = nextIdx +
|
|
91
|
+
hint.value = nextIdx + HASH_PROBE_STRIDE;
|
|
122
92
|
return hashAt(nextIdx);
|
|
123
93
|
}
|
|
124
94
|
|
|
@@ -277,7 +247,7 @@ function mapStableHashes(
|
|
|
277
247
|
const idx = hashToIndex(hash);
|
|
278
248
|
if (idx >= 0) {
|
|
279
249
|
setBit(used, idx);
|
|
280
|
-
if (idx +
|
|
250
|
+
if (idx + HASH_PROBE_STRIDE > hint.value) hint.value = idx + HASH_PROBE_STRIDE;
|
|
281
251
|
}
|
|
282
252
|
};
|
|
283
253
|
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -224,8 +224,12 @@ export function stripBarePrefixes(
|
|
|
224
224
|
matchedCount === 0
|
|
225
225
|
? "none of the stripped hashes match current file lines"
|
|
226
226
|
: `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
|
|
227
|
+
const guidance =
|
|
228
|
+
matchedCount === 0
|
|
229
|
+
? " Verify that these lines were pasted from read output; literal content starting with 'HASH│' would be altered by this strip."
|
|
230
|
+
: "";
|
|
227
231
|
warnings.push(
|
|
228
|
-
`[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence})
|
|
232
|
+
`[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).${guidance}`
|
|
229
233
|
);
|
|
230
234
|
return { ...edit, content_lines: contentLines };
|
|
231
235
|
}
|
package/src/read.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createReadTool,
|
|
4
4
|
formatSize,
|
|
5
5
|
truncateHead,
|
|
6
|
+
DEFAULT_MAX_LINES,
|
|
6
7
|
type TruncationResult,
|
|
7
8
|
} from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import { Type } from "typebox";
|
|
@@ -57,6 +58,7 @@ export async function fmtReadPreview(
|
|
|
57
58
|
precomputedHashes?: string[],
|
|
58
59
|
path?: string,
|
|
59
60
|
maxLineBytes = MAX_READ_LINE_BYTES,
|
|
61
|
+
maxTruncLines = DEFAULT_MAX_LINES,
|
|
60
62
|
): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
|
|
61
63
|
const allLines = visLines(text);
|
|
62
64
|
const totalLines = allLines.length;
|
|
@@ -99,7 +101,7 @@ export async function fmtReadPreview(
|
|
|
99
101
|
? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
|
|
100
102
|
: fmtRegion([selectedHashes[index]!], [selected[index]!]),
|
|
101
103
|
);
|
|
102
|
-
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
|
|
104
|
+
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes, maxLines: maxTruncLines });
|
|
103
105
|
const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
|
|
104
106
|
const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
|
|
105
107
|
const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
|
|
@@ -121,7 +123,7 @@ export async function fmtReadPreview(
|
|
|
121
123
|
};
|
|
122
124
|
}
|
|
123
125
|
|
|
124
|
-
const truncation = truncateHead(formatted, { maxBytes });
|
|
126
|
+
const truncation = truncateHead(formatted, { maxBytes, maxLines: maxTruncLines });
|
|
125
127
|
|
|
126
128
|
let preview = truncation.content;
|
|
127
129
|
let nextOffset: number | undefined;
|
package/src/replace.ts
CHANGED
|
@@ -190,16 +190,16 @@ export async function execPipeline(
|
|
|
190
190
|
|
|
191
191
|
const path = params.path;
|
|
192
192
|
|
|
193
|
-
const
|
|
193
|
+
const edit = resEdit({
|
|
194
|
+
hash_range_inclusive: params.hash_range_inclusive,
|
|
195
|
+
content_lines: params.content_lines,
|
|
196
|
+
});
|
|
194
197
|
|
|
198
|
+
const hashStore = options?.store ?? await loadHashStore();
|
|
195
199
|
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
|
|
196
200
|
path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore },
|
|
197
201
|
);
|
|
198
202
|
|
|
199
|
-
const edit = resEdit({
|
|
200
|
-
hash_range_inclusive: params.hash_range_inclusive,
|
|
201
|
-
content_lines: params.content_lines,
|
|
202
|
-
});
|
|
203
203
|
const anchorResult = applyEdit(
|
|
204
204
|
originalNormalized,
|
|
205
205
|
edit,
|