pi-hashline-edit-pro 4.3.3 → 4.3.5
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 +6 -6
- package/index.ts +4 -2
- package/package.json +1 -1
- package/prompts/read.md +1 -1
- package/src/anchor-registry.ts +6 -9
- package/src/auto-read-all-state.ts +22 -8
- package/src/auto-read-all.ts +32 -2
- package/src/config-ui.ts +2 -2
- package/src/glob.ts +186 -0
- package/src/grep.ts +5 -191
- package/src/hash-store.ts +1 -4
- package/src/hashline/alphabet.ts +4 -4
- package/src/hashline/parse.ts +4 -4
- package/src/read.ts +2 -2
- package/src/replace-diff.ts +2 -1
- package/src/utils.ts +12 -1
- package/src/write-hook.ts +2 -2
package/README.md
CHANGED
|
@@ -85,11 +85,11 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
|
85
85
|
}
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` work as aliases.
|
|
88
|
+
Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` and `from`/`to` work as aliases.
|
|
89
89
|
|
|
90
90
|
The request is checked before any file I/O, so a bad request never touches the file.
|
|
91
91
|
|
|
92
|
-
Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5
|
|
92
|
+
Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), diff-preview rows pasted into the replacement, and a boundary line pasted twice are reported as warnings, while a reversed range, a JSON-array wrapper, and embedded newlines are corrected silently. New lines that re-include a block adjacent to the range are stripped when that block is unique in the file. The whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it. Boundary dedup has three modes in `/hashline-config`: `on` strips with a warning, `off` applies edits literally, and `strict` rejects the edit with `[E_BOUNDARY_STRICT]` when any replacement line would be stripped.
|
|
93
93
|
Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
|
|
94
94
|
|
|
95
95
|
Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown — for example beyond an auto-read preview's truncation cap — is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
|
|
@@ -167,7 +167,7 @@ Files are filtered before injection: symlinks, directories, image extensions (in
|
|
|
167
167
|
Each attached file is shown as `=== path ===` followed by its `anchor│content` rows — edit directly from the attachment with replace and insert, no read needed. Files attach whole.
|
|
168
168
|
A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place — check that line instead of scanning sections.
|
|
169
169
|
|
|
170
|
-
The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders are ignored via `/hashline-config` as Ignore folders and via `config.json` as `autoReadAllIgnore` (array of folder names, for example `["docs", "
|
|
170
|
+
The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `anchor_grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
|
|
171
171
|
|
|
172
172
|
## Tool result details
|
|
173
173
|
|
|
@@ -184,7 +184,7 @@ All five tools return machine-readable metadata in `details` alongside the model
|
|
|
184
184
|
|
|
185
185
|
| Command | Description |
|
|
186
186
|
| --- | --- |
|
|
187
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
|
|
187
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
|
|
188
188
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
189
189
|
|
|
190
190
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -233,7 +233,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
233
233
|
| --- | --- |
|
|
234
234
|
| `[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), or content contains a NUL byte (`U+0000`), which would make the file binary. |
|
|
235
235
|
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example stringified array text that could not be parsed and was kept as one literal line). |
|
|
236
|
-
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-
|
|
236
|
+
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
|
|
237
237
|
| `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
|
|
238
238
|
| `[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. |
|
|
239
239
|
| `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
@@ -249,7 +249,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
249
249
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
250
250
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
|
251
251
|
| `[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. |
|
|
252
|
-
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line
|
|
252
|
+
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber │ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
253
253
|
| `[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. |
|
|
254
254
|
| `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
|
|
255
255
|
| `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
|
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { regGrep } from "./src/grep";
|
|
|
7
7
|
import { regUndo, clearUndo } from "./src/replace-undo";
|
|
8
8
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
9
9
|
import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-all";
|
|
10
|
+
import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
|
|
10
11
|
import type { RMetrics } from "./src/replace-response";
|
|
11
12
|
import type { ReplaceDetails } from "./src/replace";
|
|
12
13
|
import { extractWarnings } from "./src/replace-render";
|
|
@@ -103,6 +104,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
103
104
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
104
105
|
try {
|
|
105
106
|
const key = sessionKeyFor(ctx);
|
|
107
|
+
clearAutoReadAllComplete(key);
|
|
106
108
|
if (key !== undefined) releaseRegistrySession(key);
|
|
107
109
|
} catch (error) {
|
|
108
110
|
console.error("Failed to release anchor registry session:", error);
|
|
@@ -113,7 +115,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
113
115
|
if (autoReadAll === "off" || autoReadAllInjected) return;
|
|
114
116
|
autoReadAllInjected = true;
|
|
115
117
|
try {
|
|
116
|
-
const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore);
|
|
118
|
+
const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, sessionKeyFor(ctx));
|
|
117
119
|
if (!injection) return;
|
|
118
120
|
if (ctx.hasUI) ctx.ui.notify(`Auto-read all: attached ${injection.files} file(s) with anchors`, "info");
|
|
119
121
|
return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
|
|
@@ -124,7 +126,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
124
126
|
}));
|
|
125
127
|
|
|
126
128
|
pi.registerCommand("hashline-config", {
|
|
127
|
-
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders, diff context, grep, path, strict input, dedup)",
|
|
129
|
+
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input, dedup)",
|
|
128
130
|
handler: async (_args, ctx) => {
|
|
129
131
|
if (!ctx.hasUI) {
|
|
130
132
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.5",
|
|
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/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file and return it as `anchor│content` rows, one per line: a 4-character
|
|
1
|
+
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only), the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|
package/src/anchor-registry.ts
CHANGED
|
@@ -659,6 +659,7 @@ export function alignOwnershipWithSpans(
|
|
|
659
659
|
const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
|
|
660
660
|
const freed: { anchor: string; checksum: string }[] = [];
|
|
661
661
|
const minted: MintedAt[] = [];
|
|
662
|
+
const reused = new Set<string>();
|
|
662
663
|
const log = (event: RegistryEvent): void => {
|
|
663
664
|
if (!options?.shadow) appendEvent(event);
|
|
664
665
|
};
|
|
@@ -671,14 +672,12 @@ export function alignOwnershipWithSpans(
|
|
|
671
672
|
const start = span.start + offset;
|
|
672
673
|
const end = span.end + offset;
|
|
673
674
|
const spanLength = end - start + 1;
|
|
674
|
-
const spanFreed: { anchor: string; checksum: string }[] = [];
|
|
675
675
|
for (let i = start; i <= end; i++) {
|
|
676
676
|
const anchor = anchors[i]!;
|
|
677
677
|
if (state.owned.has(anchor)) {
|
|
678
678
|
state.owned.delete(anchor);
|
|
679
679
|
const checksum = prevChecksums[i - offset] ?? "";
|
|
680
|
-
|
|
681
|
-
freed.push(spanFreed[spanFreed.length - 1]!);
|
|
680
|
+
freed.push({ anchor, checksum });
|
|
682
681
|
}
|
|
683
682
|
}
|
|
684
683
|
const replacement: (string | undefined)[] = new Array(span.replacementCount);
|
|
@@ -690,10 +689,7 @@ export function alignOwnershipWithSpans(
|
|
|
690
689
|
(!state.owned.has(positional) || state.owned.get(positional)!.path === path)
|
|
691
690
|
) {
|
|
692
691
|
replacement[k] = positional;
|
|
693
|
-
|
|
694
|
-
if (freedAt >= 0) freed.splice(freedAt, 1);
|
|
695
|
-
const pooledAt = spanFreed.findIndex((candidate) => candidate.anchor === positional);
|
|
696
|
-
if (pooledAt >= 0) spanFreed.splice(pooledAt, 1);
|
|
692
|
+
reused.add(positional);
|
|
697
693
|
}
|
|
698
694
|
}
|
|
699
695
|
for (let k = 0; k < span.replacementCount; k++) {
|
|
@@ -714,8 +710,9 @@ export function alignOwnershipWithSpans(
|
|
|
714
710
|
}
|
|
715
711
|
const rows: [string, string][] = minted.map((m) => [m.anchor, m.checksum]);
|
|
716
712
|
if (rows.length > 0) log({ kind: "allocate", path, rows });
|
|
717
|
-
|
|
718
|
-
|
|
713
|
+
const retained = freed.filter((candidate) => !reused.has(candidate.anchor));
|
|
714
|
+
if (retained.length > 0) log({ kind: "free", path, anchors: retained.map((f) => f.anchor) });
|
|
715
|
+
return { anchors, freed: retained.map((f) => f.anchor), minted: minted.map((m) => m.anchor) };
|
|
719
716
|
}
|
|
720
717
|
|
|
721
718
|
export function alignOwnership(
|
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
const completeSnapshots = new Map<string, string
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
const completeSnapshots = new Map<string, Map<string, string>>();
|
|
2
|
+
|
|
3
|
+
function scopeFor(sessionKey: string | undefined): string {
|
|
4
|
+
return sessionKey ?? "";
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
export function recordAutoReadAllComplete(sessionKey: string | undefined, absolutePath: string, snapshotId: string): void {
|
|
8
|
+
const scope = scopeFor(sessionKey);
|
|
9
|
+
let snapshots = completeSnapshots.get(scope);
|
|
10
|
+
if (!snapshots) {
|
|
11
|
+
snapshots = new Map();
|
|
12
|
+
completeSnapshots.set(scope, snapshots);
|
|
13
|
+
}
|
|
14
|
+
snapshots.set(absolutePath, snapshotId);
|
|
7
15
|
}
|
|
8
|
-
|
|
9
|
-
|
|
16
|
+
|
|
17
|
+
export function getAutoReadAllSnapshot(sessionKey: string | undefined, absolutePath: string): string | undefined {
|
|
18
|
+
return completeSnapshots.get(scopeFor(sessionKey))?.get(absolutePath);
|
|
10
19
|
}
|
|
11
|
-
|
|
20
|
+
|
|
21
|
+
export function clearAutoReadAllComplete(sessionKey: string | undefined): void {
|
|
22
|
+
completeSnapshots.delete(scopeFor(sessionKey));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function clearAllAutoReadAllComplete(): void {
|
|
12
26
|
completeSnapshots.clear();
|
|
13
27
|
}
|
package/src/auto-read-all.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { normalizeAutoReadAllIgnoreEntry, type AutoReadAllMode } from "./config"
|
|
|
13
13
|
import { serveRows } from "./served";
|
|
14
14
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
15
15
|
import { resolveRgPath } from "./grep";
|
|
16
|
+
import { globToRegex } from "./glob";
|
|
16
17
|
import { MAX_HASH_LINES } from "./hashline";
|
|
17
18
|
import { fmtReadPreview } from "./read";
|
|
18
19
|
import { splitLines } from "./utils";
|
|
@@ -117,12 +118,41 @@ export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | unde
|
|
|
117
118
|
}
|
|
118
119
|
return out;
|
|
119
120
|
}
|
|
121
|
+
const GLOB_CHARS_RE = /[*?[\]{}]/;
|
|
122
|
+
const GLOB_CACHE_LIMIT = 256;
|
|
123
|
+
const globRegexCache = new Map<string, RegExp | null>();
|
|
124
|
+
|
|
125
|
+
function globRegexFor(entry: string): RegExp | null {
|
|
126
|
+
const cached = globRegexCache.get(entry);
|
|
127
|
+
if (cached !== undefined) return cached;
|
|
128
|
+
let compiled: RegExp | null;
|
|
129
|
+
try {
|
|
130
|
+
compiled = globToRegex(entry);
|
|
131
|
+
} catch {
|
|
132
|
+
compiled = null;
|
|
133
|
+
}
|
|
134
|
+
if (globRegexCache.size >= GLOB_CACHE_LIMIT) {
|
|
135
|
+
const oldest = globRegexCache.keys().next().value;
|
|
136
|
+
if (oldest !== undefined) globRegexCache.delete(oldest);
|
|
137
|
+
}
|
|
138
|
+
globRegexCache.set(entry, compiled);
|
|
139
|
+
return compiled;
|
|
140
|
+
}
|
|
141
|
+
|
|
120
142
|
export function isExcludedByCustomIgnore(path: string, customIgnore: readonly string[]): boolean {
|
|
121
143
|
if (customIgnore.length === 0) return false;
|
|
122
144
|
const lower = path.toLowerCase();
|
|
123
145
|
const segments = lower.split("/");
|
|
146
|
+
const base = segments[segments.length - 1] ?? "";
|
|
124
147
|
for (const entry of customIgnore) {
|
|
125
148
|
if (entry.length === 0) continue;
|
|
149
|
+
if (GLOB_CHARS_RE.test(entry)) {
|
|
150
|
+
const regex = globRegexFor(entry);
|
|
151
|
+
if (regex !== null) {
|
|
152
|
+
if (entry.includes("/") ? regex.test(lower) : regex.test(base)) return true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
126
156
|
if (!entry.includes("/")) {
|
|
127
157
|
if (segments.includes(entry)) return true;
|
|
128
158
|
} else if (lower === entry || lower.startsWith(entry + "/") || lower.includes("/" + entry + "/") || lower.endsWith("/" + entry)) return true;
|
|
@@ -389,7 +419,7 @@ function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted:
|
|
|
389
419
|
return `[hashline auto-read-all: ${attached} file(s) attached from ${discovery.source}; ${summary}${omissionNote}]`;
|
|
390
420
|
}
|
|
391
421
|
|
|
392
|
-
export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = []): Promise<AutoReadAllInjection | undefined> {
|
|
422
|
+
export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = [], sessionKey?: string): Promise<AutoReadAllInjection | undefined> {
|
|
393
423
|
const discovery = await discoverAutoReadAllFiles(cwd, mode, ignoreDirs);
|
|
394
424
|
if (discovery.files.length === 0) return undefined;
|
|
395
425
|
const sections: AutoReadAllSection[] = [];
|
|
@@ -409,7 +439,7 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
|
|
|
409
439
|
}
|
|
410
440
|
sections.push(section);
|
|
411
441
|
const snapshotId = await safeSnapId(section.absolutePath, "auto-read-all");
|
|
412
|
-
if (snapshotId !== undefined) recordAutoReadAllComplete(section.absolutePath, snapshotId);
|
|
442
|
+
if (snapshotId !== undefined) recordAutoReadAllComplete(sessionKey, section.absolutePath, snapshotId);
|
|
413
443
|
bytes += sectionBytes;
|
|
414
444
|
completeFiles += 1;
|
|
415
445
|
}
|
package/src/config-ui.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
20
20
|
return [
|
|
21
21
|
{ key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
|
|
22
22
|
{ key: "autoReadAll", label: "Auto-read all", hint: "Attach files on the first turn: off, on, git (git repos only)", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "on", "git"] },
|
|
23
|
-
{ key: "autoReadAllIgnore", label: "Ignore folders", hint: "Extra folders skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
|
|
23
|
+
{ key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
|
|
24
24
|
{ key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
|
|
25
25
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
26
26
|
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|
|
@@ -205,7 +205,7 @@ export class HashlineConfigOverlay {
|
|
|
205
205
|
}
|
|
206
206
|
});
|
|
207
207
|
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
208
|
-
const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit
|
|
208
|
+
const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · q close";
|
|
209
209
|
lines.push(padRow(theme, innerWidth, theme.fg("dim", footer)));
|
|
210
210
|
lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
211
211
|
return lines;
|
package/src/glob.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
function bracketEnd(source: string, start: number): number {
|
|
2
|
+
let j = start + 1;
|
|
3
|
+
if (j < source.length && (source[j] === "!" || source[j] === "^")) j++;
|
|
4
|
+
if (j < source.length && source[j] === "]") j++;
|
|
5
|
+
let esc = false;
|
|
6
|
+
while (j < source.length) {
|
|
7
|
+
const c = source[j]!;
|
|
8
|
+
if (esc) {
|
|
9
|
+
esc = false;
|
|
10
|
+
j++;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (c === "\\") {
|
|
14
|
+
esc = true;
|
|
15
|
+
j++;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (c === "]") return j;
|
|
19
|
+
j++;
|
|
20
|
+
}
|
|
21
|
+
return -1;
|
|
22
|
+
}
|
|
23
|
+
function globPartToSource(glob: string): string {
|
|
24
|
+
let source = "";
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < glob.length) {
|
|
27
|
+
const ch = glob[i]!;
|
|
28
|
+
if (ch === "\\" && i + 1 < glob.length) {
|
|
29
|
+
const next = glob[i + 1]!;
|
|
30
|
+
source += next.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
i += 2;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === "*") {
|
|
35
|
+
if (glob[i + 1] === "*") {
|
|
36
|
+
i += 2;
|
|
37
|
+
if (glob[i] === "/") {
|
|
38
|
+
i += 1;
|
|
39
|
+
source += "(?:.*\\/)?";
|
|
40
|
+
} else {
|
|
41
|
+
source += ".*";
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
source += ".*";
|
|
46
|
+
i += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ch === "?") {
|
|
50
|
+
source += "[^/]";
|
|
51
|
+
i += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === "{") {
|
|
55
|
+
let depth = 1;
|
|
56
|
+
let j = i + 1;
|
|
57
|
+
let esc = false;
|
|
58
|
+
while (j < glob.length && depth > 0) {
|
|
59
|
+
const c = glob[j]!;
|
|
60
|
+
if (esc) {
|
|
61
|
+
esc = false;
|
|
62
|
+
j++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (c === "\\") {
|
|
66
|
+
esc = true;
|
|
67
|
+
j++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (c === "[") {
|
|
71
|
+
const e = bracketEnd(glob, j);
|
|
72
|
+
if (e >= 0) {
|
|
73
|
+
j = e + 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
j++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (c === "{") depth++;
|
|
80
|
+
else if (c === "}") depth--;
|
|
81
|
+
if (depth === 0) break;
|
|
82
|
+
j++;
|
|
83
|
+
}
|
|
84
|
+
if (depth !== 0) {
|
|
85
|
+
source += "\\{";
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const inner = glob.slice(i + 1, j);
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
let cur = "";
|
|
92
|
+
let d2 = 0;
|
|
93
|
+
let esc2 = false;
|
|
94
|
+
for (let k = 0; k < inner.length; k++) {
|
|
95
|
+
const c = inner[k]!;
|
|
96
|
+
if (esc2) {
|
|
97
|
+
cur += c;
|
|
98
|
+
esc2 = false;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === "\\") {
|
|
102
|
+
esc2 = true;
|
|
103
|
+
cur += c;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === "[") {
|
|
107
|
+
const e = bracketEnd(inner, k);
|
|
108
|
+
if (e >= 0) {
|
|
109
|
+
cur += inner.slice(k, e + 1);
|
|
110
|
+
k = e;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
cur += c;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === "{") {
|
|
117
|
+
d2++;
|
|
118
|
+
cur += c;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (c === "}") {
|
|
122
|
+
d2--;
|
|
123
|
+
cur += c;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (c === "," && d2 === 0) {
|
|
127
|
+
parts.push(cur);
|
|
128
|
+
cur = "";
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
cur += c;
|
|
132
|
+
}
|
|
133
|
+
parts.push(cur);
|
|
134
|
+
if (parts.length <= 1) {
|
|
135
|
+
source += "\\{" + globPartToSource(inner) + "\\}";
|
|
136
|
+
} else {
|
|
137
|
+
source += "(?:" + parts.map((p) => globPartToSource(p)).join("|") + ")";
|
|
138
|
+
}
|
|
139
|
+
i = j + 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (ch === "[") {
|
|
143
|
+
let j = i + 1;
|
|
144
|
+
if (j < glob.length && (glob[j] === "!" || glob[j] === "^")) j++;
|
|
145
|
+
if (j < glob.length && glob[j] === "]") j++;
|
|
146
|
+
let esc3 = false;
|
|
147
|
+
while (j < glob.length) {
|
|
148
|
+
const c = glob[j]!;
|
|
149
|
+
if (esc3) {
|
|
150
|
+
esc3 = false;
|
|
151
|
+
j++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (c === "\\") {
|
|
155
|
+
esc3 = true;
|
|
156
|
+
j++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (c === "]") break;
|
|
160
|
+
j++;
|
|
161
|
+
}
|
|
162
|
+
if (j >= glob.length) {
|
|
163
|
+
source += "\\[";
|
|
164
|
+
i++;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
let content = glob.slice(i + 1, j);
|
|
168
|
+
if (content.startsWith("!")) content = "^" + content.slice(1);
|
|
169
|
+
if (content.startsWith("]") || content.startsWith("^]")) {
|
|
170
|
+
if (content.startsWith("^]")) content = "^\\]" + content.slice(2);
|
|
171
|
+
else content = "\\]" + content.slice(1);
|
|
172
|
+
}
|
|
173
|
+
if (content.startsWith("^") && !content.includes("/")) content = "^/" + content.slice(1);
|
|
174
|
+
source += "[" + content + "]";
|
|
175
|
+
i = j + 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
179
|
+
i++;
|
|
180
|
+
}
|
|
181
|
+
return source;
|
|
182
|
+
}
|
|
183
|
+
export function globToRegex(glob: string): RegExp {
|
|
184
|
+
if (glob.startsWith("/")) glob = glob.slice(1);
|
|
185
|
+
return new RegExp(`^${globPartToSource(glob)}$`);
|
|
186
|
+
}
|
package/src/grep.ts
CHANGED
|
@@ -3,10 +3,12 @@ import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { stat } from "node:fs/promises";
|
|
5
5
|
import { dirname, isAbsolute, join, win32 } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
6
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
8
|
import { createInterface } from "node:readline";
|
|
8
9
|
import { tryReadNormFile } from "./file-reader";
|
|
9
|
-
import {
|
|
10
|
+
import { globToRegex } from "./glob";
|
|
11
|
+
import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP, HASH_CLASS } from "./hashline";
|
|
10
12
|
import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
|
|
11
13
|
import { toCwd, toDisplayPath } from "./paths";
|
|
12
14
|
import { loadP, loadGuide } from "./prompts";
|
|
@@ -152,193 +154,6 @@ function assertSafeRegex(pattern: string): void {
|
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
function bracketEnd(source: string, start: number): number {
|
|
156
|
-
let j = start + 1;
|
|
157
|
-
if (j < source.length && (source[j] === "!" || source[j] === "^")) j++;
|
|
158
|
-
if (j < source.length && source[j] === "]") j++;
|
|
159
|
-
let esc = false;
|
|
160
|
-
while (j < source.length) {
|
|
161
|
-
const c = source[j]!;
|
|
162
|
-
if (esc) {
|
|
163
|
-
esc = false;
|
|
164
|
-
j++;
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (c === "\\") {
|
|
168
|
-
esc = true;
|
|
169
|
-
j++;
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
if (c === "]") return j;
|
|
173
|
-
j++;
|
|
174
|
-
}
|
|
175
|
-
return -1;
|
|
176
|
-
}
|
|
177
|
-
function globPartToSource(glob: string): string {
|
|
178
|
-
let source = "";
|
|
179
|
-
let i = 0;
|
|
180
|
-
while (i < glob.length) {
|
|
181
|
-
const ch = glob[i]!;
|
|
182
|
-
if (ch === "\\" && i + 1 < glob.length) {
|
|
183
|
-
const next = glob[i + 1]!;
|
|
184
|
-
source += next.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
185
|
-
i += 2;
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (ch === "*") {
|
|
189
|
-
if (glob[i + 1] === "*") {
|
|
190
|
-
i += 2;
|
|
191
|
-
if (glob[i] === "/") {
|
|
192
|
-
i += 1;
|
|
193
|
-
source += "(?:.*\\/)?";
|
|
194
|
-
} else {
|
|
195
|
-
source += ".*";
|
|
196
|
-
}
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
source += ".*";
|
|
200
|
-
i += 1;
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (ch === "?") {
|
|
204
|
-
source += "[^/]";
|
|
205
|
-
i += 1;
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (ch === "{") {
|
|
209
|
-
let depth = 1;
|
|
210
|
-
let j = i + 1;
|
|
211
|
-
let esc = false;
|
|
212
|
-
while (j < glob.length && depth > 0) {
|
|
213
|
-
const c = glob[j]!;
|
|
214
|
-
if (esc) {
|
|
215
|
-
esc = false;
|
|
216
|
-
j++;
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
if (c === "\\") {
|
|
220
|
-
esc = true;
|
|
221
|
-
j++;
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
if (c === "[") {
|
|
225
|
-
const e = bracketEnd(glob, j);
|
|
226
|
-
if (e >= 0) {
|
|
227
|
-
j = e + 1;
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
j++;
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
if (c === "{") depth++;
|
|
234
|
-
else if (c === "}") depth--;
|
|
235
|
-
if (depth === 0) break;
|
|
236
|
-
j++;
|
|
237
|
-
}
|
|
238
|
-
if (depth !== 0) {
|
|
239
|
-
source += "\\{";
|
|
240
|
-
i++;
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
const inner = glob.slice(i + 1, j);
|
|
244
|
-
const parts: string[] = [];
|
|
245
|
-
let cur = "";
|
|
246
|
-
let d2 = 0;
|
|
247
|
-
let esc2 = false;
|
|
248
|
-
for (let k = 0; k < inner.length; k++) {
|
|
249
|
-
const c = inner[k]!;
|
|
250
|
-
if (esc2) {
|
|
251
|
-
cur += c;
|
|
252
|
-
esc2 = false;
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
if (c === "\\") {
|
|
256
|
-
esc2 = true;
|
|
257
|
-
cur += c;
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
if (c === "[") {
|
|
261
|
-
const e = bracketEnd(inner, k);
|
|
262
|
-
if (e >= 0) {
|
|
263
|
-
cur += inner.slice(k, e + 1);
|
|
264
|
-
k = e;
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
cur += c;
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
if (c === "{") {
|
|
271
|
-
d2++;
|
|
272
|
-
cur += c;
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
if (c === "}") {
|
|
276
|
-
d2--;
|
|
277
|
-
cur += c;
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
if (c === "," && d2 === 0) {
|
|
281
|
-
parts.push(cur);
|
|
282
|
-
cur = "";
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
cur += c;
|
|
286
|
-
}
|
|
287
|
-
parts.push(cur);
|
|
288
|
-
if (parts.length <= 1) {
|
|
289
|
-
source += "\\{" + globPartToSource(inner) + "\\}";
|
|
290
|
-
} else {
|
|
291
|
-
source += "(?:" + parts.map((p) => globPartToSource(p)).join("|") + ")";
|
|
292
|
-
}
|
|
293
|
-
i = j + 1;
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
if (ch === "[") {
|
|
297
|
-
let j = i + 1;
|
|
298
|
-
if (j < glob.length && (glob[j] === "!" || glob[j] === "^")) j++;
|
|
299
|
-
if (j < glob.length && glob[j] === "]") j++;
|
|
300
|
-
let esc3 = false;
|
|
301
|
-
while (j < glob.length) {
|
|
302
|
-
const c = glob[j]!;
|
|
303
|
-
if (esc3) {
|
|
304
|
-
esc3 = false;
|
|
305
|
-
j++;
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
if (c === "\\") {
|
|
309
|
-
esc3 = true;
|
|
310
|
-
j++;
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
if (c === "]") break;
|
|
314
|
-
j++;
|
|
315
|
-
}
|
|
316
|
-
if (j >= glob.length) {
|
|
317
|
-
source += "\\[";
|
|
318
|
-
i++;
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
let content = glob.slice(i + 1, j);
|
|
322
|
-
if (content.startsWith("!")) content = "^" + content.slice(1);
|
|
323
|
-
if (content.startsWith("]") || content.startsWith("^]")) {
|
|
324
|
-
if (content.startsWith("^]")) content = "^\\]" + content.slice(2);
|
|
325
|
-
else content = "\\]" + content.slice(1);
|
|
326
|
-
}
|
|
327
|
-
if (content.startsWith("^") && !content.includes("/")) content = "^/" + content.slice(1);
|
|
328
|
-
source += "[" + content + "]";
|
|
329
|
-
i = j + 1;
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
333
|
-
i++;
|
|
334
|
-
}
|
|
335
|
-
return source;
|
|
336
|
-
}
|
|
337
|
-
function globToRegex(glob: string): RegExp {
|
|
338
|
-
if (glob.startsWith("/")) glob = glob.slice(1);
|
|
339
|
-
return new RegExp(`^${globPartToSource(glob)}$`);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
157
|
interface FileHit {
|
|
343
158
|
path: string;
|
|
344
159
|
displayPath: string;
|
|
@@ -462,10 +277,9 @@ export async function resolveRgPath(): Promise<string> {
|
|
|
462
277
|
const { createRequire } = await import("node:module");
|
|
463
278
|
const require = createRequire(import.meta.url);
|
|
464
279
|
const pkgPath = require.resolve("@earendil-works/pi-coding-agent/package.json");
|
|
465
|
-
const { dirname } = await import("node:path");
|
|
466
280
|
const piDir = dirname(pkgPath);
|
|
467
281
|
const toolsManagerPath = join(piDir, "dist/utils/tools-manager.js");
|
|
468
|
-
const mod = await import(
|
|
282
|
+
const mod = await import(pathToFileURL(toolsManagerPath).href);
|
|
469
283
|
if (mod.ensureTool) {
|
|
470
284
|
const p = await mod.ensureTool("rg", true);
|
|
471
285
|
if (p) { cachedRgPath = p; return p; }
|
|
@@ -654,7 +468,7 @@ function highlightMatches(text: string, regex: RegExp, theme: FgT): string {
|
|
|
654
468
|
return out + text.slice(last);
|
|
655
469
|
}
|
|
656
470
|
|
|
657
|
-
const ANCHORED_ROW_RE =
|
|
471
|
+
const ANCHORED_ROW_RE = new RegExp(`^${HASH_CLASS}${HASH_SEP}`);
|
|
658
472
|
|
|
659
473
|
function highlightHitRow(row: string, highlight: RegExp, theme: FgT): string {
|
|
660
474
|
const anchored = row.match(ANCHORED_ROW_RE);
|
package/src/hash-store.ts
CHANGED
|
@@ -117,7 +117,6 @@ interface Prepared {
|
|
|
117
117
|
get: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
118
118
|
getState: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
119
119
|
allPaths: (...params: SqlParams) => Record<string, unknown>[];
|
|
120
|
-
allHashes: (...params: SqlParams) => Record<string, unknown>[];
|
|
121
120
|
deleteOne: (...params: SqlParams) => void;
|
|
122
121
|
upsert: (...params: SqlParams) => void;
|
|
123
122
|
undoUpsert: (...params: SqlParams) => void;
|
|
@@ -205,7 +204,6 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
205
204
|
const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
|
|
206
205
|
const getStateStmt = db.prepare("SELECT checksum, hashes, line_checksums FROM snapshots WHERE path = ?");
|
|
207
206
|
const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
|
|
208
|
-
const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
|
|
209
207
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
210
208
|
const upsertStmt = db.prepare(
|
|
211
209
|
"INSERT INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?) " +
|
|
@@ -224,8 +222,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
224
222
|
const stmts: Prepared = {
|
|
225
223
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
226
224
|
getState: (...params) => getStateStmt.get(...params) as Record<string, unknown> | undefined,
|
|
227
|
-
allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
|
|
228
|
-
allHashes: (...params) => allHashesStmt.all(...params) as Record<string, unknown>[],
|
|
225
|
+
allPaths: (...params) => withBusyRetry(() => allStmt.all(...params) as Record<string, unknown>[]),
|
|
229
226
|
deleteOne: retriedWrite(delStmt),
|
|
230
227
|
upsert: retriedWrite(upsertStmt),
|
|
231
228
|
undoUpsert: retriedWrite(undoUpsertStmt),
|
package/src/hashline/alphabet.ts
CHANGED
|
@@ -6,13 +6,13 @@ if (typeof rawAnchors !== "string" || rawAnchors.length === 0 || rawAnchors.leng
|
|
|
6
6
|
const TABLE: string = rawAnchors;
|
|
7
7
|
export const HASH_LEN = 4;
|
|
8
8
|
export const ANCHOR_COUNT = TABLE.length / HASH_LEN;
|
|
9
|
-
const
|
|
9
|
+
const LETTERS = "A-Za-z";
|
|
10
10
|
|
|
11
|
-
export const ALPH_RE = new RegExp(`^[${
|
|
11
|
+
export const ALPH_RE = new RegExp(`^[${LETTERS}]+$`);
|
|
12
12
|
|
|
13
|
-
export const HASH_CLASS = `[${
|
|
13
|
+
export const HASH_CLASS = `[${LETTERS}]{${HASH_LEN}}`;
|
|
14
14
|
|
|
15
|
-
export const HASH_RUN = `[${
|
|
15
|
+
export const HASH_RUN = `[${LETTERS}]{${HASH_LEN},${HASH_LEN + 1}}`;
|
|
16
16
|
|
|
17
17
|
export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
|
|
18
18
|
|
package/src/hashline/parse.ts
CHANGED
|
@@ -13,7 +13,7 @@ function diagRef(ref: string): string {
|
|
|
13
13
|
const trimmed = ref.trim();
|
|
14
14
|
|
|
15
15
|
if (!trimmed.length) {
|
|
16
|
-
return `[E_BAD_REF] Invalid anchor. Expected a 4-
|
|
16
|
+
return `[E_BAD_REF] Invalid anchor. Expected a 4-character anchor (letters only, e.g. "Hasu").`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
if (/^\d+/.test(trimmed)) {
|
|
@@ -29,13 +29,13 @@ function diagRef(ref: string): string {
|
|
|
29
29
|
const firstHash = firstMatch?.[0] ?? "Hasu";
|
|
30
30
|
const lastHash = lastMatch?.[0] ?? "Hasu";
|
|
31
31
|
const preview = first.slice(0, 60);
|
|
32
|
-
return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 4-
|
|
32
|
+
return `[E_BAD_REF] Invalid anchor — remove_from and remove_to must each be a single bare 4-character anchor (letters only, e.g. "Hasu"), not a block with HASH│content. Received ${lines.length} lines starting "${preview}…" — use only the first hash "${firstHash}" as remove_from and "${lastHash}" as remove_to, and put the new content (without HASH│) in replacement_lines.`;
|
|
33
33
|
}
|
|
34
34
|
if (trimmed.includes("│")) {
|
|
35
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 4-
|
|
35
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 4-character anchor, drop everything from "│" onward.`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-
|
|
38
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-character anchor (letters only, e.g. "Hasu").`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function parseRef(ref: string): Anchor {
|
package/src/read.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-
|
|
|
19
19
|
import { valAccess } from "./validation";
|
|
20
20
|
import { readConfig } from "./config";
|
|
21
21
|
import { resolveTarget } from "./fs-write";
|
|
22
|
-
import { withAnchorSession, servedForPath } from "./anchor-registry";
|
|
22
|
+
import { withAnchorSession, servedForPath, sessionKeyFor } from "./anchor-registry";
|
|
23
23
|
import { serveRows } from "./served";
|
|
24
24
|
import { getAutoReadAllSnapshot } from "./auto-read-all-state";
|
|
25
25
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -217,7 +217,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
217
217
|
if (autoReadAllMode !== "off") {
|
|
218
218
|
const canonical = await resolveTarget(absolutePath).catch(() => undefined);
|
|
219
219
|
if (canonical !== undefined) {
|
|
220
|
-
const stored = getAutoReadAllSnapshot(canonical);
|
|
220
|
+
const stored = getAutoReadAllSnapshot(sessionKeyFor(ctx), canonical);
|
|
221
221
|
if (stored !== undefined) {
|
|
222
222
|
const current = await safeSnapId(canonical, "auto-read-all guard");
|
|
223
223
|
if (current !== undefined && current === stored) {
|
package/src/replace-diff.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
_lineHashesPure,
|
|
5
5
|
ANCHOR_LEN,
|
|
6
6
|
HASH_SEP,
|
|
7
|
+
HASH_CLASS,
|
|
7
8
|
changedRange,
|
|
8
9
|
} from "./hashline";
|
|
9
10
|
import { MAX_DIFF_INPUT_BYTES, DEDUP_ANCHOR } from "./constants";
|
|
@@ -111,7 +112,7 @@ export function spansFromHashes(oldHashes: string[], newHashes: string[]): DiffS
|
|
|
111
112
|
return spans;
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
const ANCHORED_DIFF_ROW_RE =
|
|
115
|
+
const ANCHORED_DIFF_ROW_RE = new RegExp(`^([+ -])(${HASH_CLASS})│`);
|
|
115
116
|
|
|
116
117
|
export function disambiguateDuplicateAnchors(diff: string): string {
|
|
117
118
|
if (!diff.includes("│")) return diff;
|
package/src/utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NUL_CONTENT_MSG } from "./constants";
|
|
2
|
+
import { HASH_CLASS } from "./hashline/alphabet";
|
|
2
3
|
|
|
3
4
|
export function isRec(value: unknown): value is Record<string, unknown> {
|
|
4
5
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -20,6 +21,14 @@ export function normalizeAnchors(record: Record<string, unknown>): void {
|
|
|
20
21
|
record.remove_to = record.replace_to;
|
|
21
22
|
delete record.replace_to;
|
|
22
23
|
}
|
|
24
|
+
if (typeof record.remove_from !== "string" && typeof record.from === "string") {
|
|
25
|
+
record.remove_from = record.from;
|
|
26
|
+
delete record.from;
|
|
27
|
+
}
|
|
28
|
+
if (typeof record.remove_to !== "string" && typeof record.to === "string") {
|
|
29
|
+
record.remove_to = record.to;
|
|
30
|
+
delete record.to;
|
|
31
|
+
}
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export function normalizeRequest(input: unknown): unknown {
|
|
@@ -133,8 +142,10 @@ export function getCached<K, V>(map: Map<K, V>, key: K, compute: (key: K) => V):
|
|
|
133
142
|
return v;
|
|
134
143
|
}
|
|
135
144
|
|
|
145
|
+
const HASH_ROW_RE = new RegExp(`^${HASH_CLASS}│`);
|
|
146
|
+
|
|
136
147
|
export function isHashRow(line: string): boolean {
|
|
137
|
-
return
|
|
148
|
+
return HASH_ROW_RE.test(line);
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
export function gutterWidth(max: number, fallback: number): number {
|
package/src/write-hook.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
|
5
5
|
import { resolveInCwd } from "./fs-write";
|
|
6
6
|
import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
|
|
7
7
|
|
|
8
|
-
const HASH_ECHO_RE = new RegExp(`^(${HASH_CLASS})${HASH_SEP}`);
|
|
8
|
+
const HASH_ECHO_RE = new RegExp(`^(?: *[0-9]+ ${HASH_SEP} )?[+ -]?(${HASH_CLASS})${HASH_SEP}`);
|
|
9
9
|
|
|
10
10
|
function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
11
11
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -27,7 +27,7 @@ export async function servedHashEchoDenial(rawPath: string, content: string, cwd
|
|
|
27
27
|
if (!served || served.size === 0) return undefined;
|
|
28
28
|
const echo = findServedHashEcho(content, served);
|
|
29
29
|
if (!echo) return undefined;
|
|
30
|
-
return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line}
|
|
30
|
+
return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line} contains the copied ${echo.hash}${HASH_SEP} anchor served for this file. Remove the copied anchors and retry.`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function registerWriteHook(pi: ExtensionAPI): void {
|