pi-hashline-edit-pro 6.4.2 → 6.4.3

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 CHANGED
@@ -219,13 +219,13 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
219
219
 
220
220
  ### replace_match
221
221
 
222
- `replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. Send `old_string` as only the fragment that changes — never include text that `new_string` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines or the whole range. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
222
+ `replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. Send `old_string` as only the fragment that changes — never include text that `new_string` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines or the whole range; if `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line change. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
223
223
 
224
224
  `remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
225
225
 
226
226
  `old_string` is matched against the range's text (LF line breaks, no final terminator) and every non-overlapping occurrence is replaced, left to right. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are matched against the file as it currently stands on disk.
227
227
 
228
- `replace_match` reports `[W_REPLACE_MATCH_WHOLE_RANGE]` when `old_string` is the entire selected range, `[W_REPLACE_MATCH_RETYPE]` when `old_string` retypes a long run of text that `new_string` leaves unchanged, and `[W_REPLACE_MATCH_MULTI]` when `old_string` matched more than one occurrence. In strict-input mode the first two are refused with `[E_BAD_SHAPE]`.
228
+ `replace_match` reports `[W_REPLACE_MATCH_WHOLE_RANGE]` when `old_string` is the entire selected range, and `[W_REPLACE_MATCH_RETYPE]` when `old_string` retypes a long run of text that `new_string` leaves unchanged. Both are refused in strict-input mode with `[E_BAD_SHAPE]`.
229
229
 
230
230
  In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
231
231
 
@@ -465,7 +465,6 @@ Full reference:
465
465
  | `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
466
466
  | `[W_REPLACE_MATCH_WHOLE_RANGE]` | `replace_match` `old_string` is the entire selected range. The anchors already identify it; use `replace` with the same anchors, or send only the changed fragment. Refused in strict-input mode. |
467
467
  | `[W_REPLACE_MATCH_RETYPE]` | `replace_match` `old_string` retypes a long run of text that `new_string` leaves unchanged. Send only the fragment that changes; refused in strict-input mode. |
468
- | `[W_REPLACE_MATCH_MULTI]` | `replace_match` `old_string` matched more than one occurrence in the range and every one was replaced. Narrow the range or send a longer fragment if you meant only one. |
469
468
  | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
470
469
  | `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
471
470
  | `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the character, its column, and the reference anchor, for example `[H_UNICODE_LOST] U+2060 missing at col 31; Kq3f│ has it; resend with U+2060 if unintended.` When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
package/index.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  setDisableOnModelsFromText,
34
34
  } from "./src/config";
35
35
  import { loadHashStore, pruneMissing } from "./src/hash-store";
36
- import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
36
+ import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
37
37
  import { serveRows } from "./src/served";
38
38
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
39
39
  import { currentEditFlags } from "./src/edit-common";
@@ -154,8 +154,7 @@ export default function (pi: ExtensionAPI): void {
154
154
  await initHasher();
155
155
  loadHashStore()
156
156
  .then(async store => {
157
- const missing = await pruneMissing(store);
158
- for (const path of missing) freeAnchors(path);
157
+ freeAnchorsForPaths(await pruneMissing(store));
159
158
  })
160
159
  .catch(err => {
161
160
  console.error("Failed to load hash store:", err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.2",
3
+ "version": "6.4.3",
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",
@@ -512,7 +512,7 @@ function hasOwnedPath(state: SessionState, path: string): boolean {
512
512
 
513
513
  function evictPath(state: SessionState, path: string): string[] {
514
514
  const anchors: string[] = [];
515
- for (const [anchor, entry] of [...state.owned]) {
515
+ for (const [anchor, entry] of state.owned) {
516
516
  if (entry.path === path) {
517
517
  anchors.push(anchor);
518
518
  state.owned.delete(anchor);
@@ -617,6 +617,28 @@ export function freeAnchors(path: string, anchors?: string[]): void {
617
617
  }
618
618
  }
619
619
 
620
+ export function freeAnchorsForPaths(paths: Iterable<string>): void {
621
+ const state = current();
622
+ if (!state) return;
623
+ const targets = new Set(paths);
624
+ if (targets.size === 0) return;
625
+ const freedByPath = new Map<string, string[]>();
626
+ for (const [anchor, entry] of state.owned) {
627
+ if (!targets.has(entry.path)) continue;
628
+ state.owned.delete(anchor);
629
+ const freed = freedByPath.get(entry.path);
630
+ if (freed === undefined) freedByPath.set(entry.path, [anchor]);
631
+ else freed.push(anchor);
632
+ }
633
+ for (const path of targets) {
634
+ state.served.delete(path);
635
+ state.lastUsedAt.delete(path);
636
+ }
637
+ for (const [path, anchors] of freedByPath) {
638
+ appendEvent({ kind: "free", path, anchors });
639
+ }
640
+ }
641
+
620
642
  export function clearRegistry(): void {
621
643
  const state = current();
622
644
  if (!state) return;
@@ -47,7 +47,7 @@ export async function currentEditFlags(codemode = false): Promise<EditToolFlags>
47
47
 
48
48
  function preferenceGuideline(flags: EditToolFlags): string {
49
49
  const tools = gatedEditOps(["read", "replace", "replace_match", "insert", "copy", "move", "undo_last_change"], flags);
50
- return `Prefer the hashline tools for every file read and edit: ${joinOps(tools, { backtick: true })}.`;
50
+ return `Remember to use the hashline tools for every file read and edit: ${joinOps(tools, { backtick: true })}.`;
51
51
  }
52
52
 
53
53
  const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
package/src/hash-store.ts CHANGED
@@ -128,7 +128,7 @@ function requireEngine(): EngineSelection {
128
128
  interface Prepared {
129
129
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
130
130
  getState: (...params: SqlParams) => Record<string, unknown> | undefined;
131
- allPaths: (...params: SqlParams) => Record<string, unknown>[];
131
+ snapshotPaths: (...params: SqlParams) => Record<string, unknown>[];
132
132
  deleteOne: (...params: SqlParams) => void;
133
133
  upsert: (...params: SqlParams) => void;
134
134
  undoUpsert: (...params: SqlParams) => void;
@@ -226,7 +226,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
226
226
  ).run(String(HASH_STORE_VERSION));
227
227
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
228
228
  const getStateStmt = db.prepare("SELECT checksum, hashes, line_checksums FROM snapshots WHERE path = ?");
229
- const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
229
+ const snapshotPathsStmt = db.prepare("SELECT path FROM snapshots");
230
230
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
231
231
  const upsertStmt = db.prepare(
232
232
  "INSERT INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?) " +
@@ -245,7 +245,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
245
245
  const stmts: Prepared = {
246
246
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
247
247
  getState: (...params) => getStateStmt.get(...params) as Record<string, unknown> | undefined,
248
- allPaths: (...params) => withBusyRetry(() => allStmt.all(...params) as Record<string, unknown>[]),
248
+ snapshotPaths: (...params) => withBusyRetry(() => snapshotPathsStmt.all(...params) as Record<string, unknown>[]),
249
249
  deleteOne: retriedWrite(delStmt),
250
250
  upsert: retriedWrite(upsertStmt),
251
251
  undoUpsert: retriedWrite(undoUpsertStmt),
@@ -640,7 +640,7 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
640
640
  }
641
641
 
642
642
  export async function pruneMissing(store: HashStore): Promise<string[]> {
643
- const rows = store.stmts.allPaths() as { path: string }[];
643
+ const rows = store.stmts.snapshotPaths() as { path: string }[];
644
644
  const missing = await statMissing(rows);
645
645
  if (missing.length === 0) return [];
646
646
  withStore(() => {
@@ -234,7 +234,7 @@ const replaceMatchToSchema = Type.String({
234
234
  });
235
235
  const replaceMatchOldStringSchema = Type.String({
236
236
  description:
237
- "The exact text to find inside the selected line(s), copied from the served row: only the fragment that changes, never text that `new_string` leaves unchanged. Every occurrence inside the range is replaced; the text around each match is left untouched. Matching uses LF line breaks and excludes the last line's terminator. For a whole-line or whole-range rewrite use `replace` with the same anchors.",
237
+ "The exact text to find inside the selected line(s), copied from the served row: only the fragment that changes, never text that `new_string` leaves unchanged. If `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line match: use `replace` instead. Every occurrence inside the range is replaced; the text around each match is left untouched. Matching uses LF line breaks and excludes the last line's terminator.",
238
238
  });
239
239
  const replaceMatchNewStringSchema = Type.String({
240
240
  description:
@@ -150,10 +150,6 @@ function wholeRangeWarning(oldText: string, rangeText: string, lineCount: number
150
150
  return `[W_REPLACE_MATCH_WHOLE_RANGE] "old_string" is the entire selected range (${formatMatchRange(start, end)}); the anchors already identify it. Use replace with remove_from "${startHash}", remove_to "${endHash}" and the new text, or send only the fragment that changes.`;
151
151
  }
152
152
 
153
- function multiMatchWarning(count: number, start: number, end: number, displayPath: string): string {
154
- return `[W_REPLACE_MATCH_MULTI] "old_string" matched ${count} occurrences in ${formatMatchRange(start, end)} of ${displayPath}; every one was replaced. Narrow the range or send a longer fragment if you meant only one.`;
155
- }
156
-
157
153
  function describeChar(char: string | undefined): string {
158
154
  if (char === undefined) return "the end of the text";
159
155
  const codePoint = char.codePointAt(0)!;
@@ -225,7 +221,6 @@ export function buildReplaceMatchEdit(
225
221
  const wholeRange = wholeRangeWarning(oldText, rangeText, rangeLines.length, start, end, startRef.hash, endRef.hash);
226
222
  if (wholeRange !== undefined) warnings.push(wholeRange);
227
223
  }
228
- if (count > 1) warnings.push(multiMatchWarning(count, start, end, displayPath));
229
224
  return {
230
225
  editParams: {
231
226
  remove_from: startRef.hash,
@@ -1,3 +1,3 @@
1
- - `replace_match`: `old_string` is only the fragment of the line that changes — never the whole line or range, and never text that `new_string` leaves unchanged; every occurrence inside the range is replaced, so scope the range with anchors when only some occurrences should change.
2
- - `replace_match`: when the same changed fragment appears on every matching line in a range, anchor the first and last matching lines so one call replaces them all instead of one call per line.
3
- - `replace_match`: when the change covers whole lines or the whole range, use `replace` with the same anchors and the new text.
1
+ - `replace_match`: `old_string` is only the fragment that changes — never text that `new_string` leaves unchanged; every occurrence inside the range is replaced, so scope the range when only some lines should change.
2
+ - `replace_match`: if `old_string` is everything on the line except indentation and trailing punctuation, that match is the whole line — use `replace`. A shorter fragment repeated on several lines (a value, a name) is the intended multi-line use.
3
+ - `replace_match`: when the change covers whole lines or the whole range, use `replace` with the same anchors and the exact text.
@@ -1,3 +1,3 @@
1
- Replace part of a line (or a range of lines) without retyping the rest. `replace_from`/`replace_to` are bare anchors from served `anchor│content` rows marking the range's first and last line (the same anchor for one line), and `old_string` is the exact text to find inside that range. Send only the smallest fragment that contains the change — never text that `new_string` leaves unchanged; for a whole-line or whole-range rewrite use `replace` with the same anchors instead. `remove_from`/`remove_to` are accepted as aliases.
1
+ Replace part of a line (or a range of lines) without retyping the rest. `replace_from`/`replace_to` are bare anchors from served `anchor│content` rows marking the range's first and last line (the same anchor for one line), and `old_string` is the exact text to find inside that range. Send only the fragment that changes — never text that `new_string` leaves unchanged. If `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line rewrite: use `replace` with the same anchors. The same short fragment on several lines (a value, a name) may be replaced in one call. `remove_from`/`remove_to` are accepted as aliases.
2
2
 
3
3
  The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.