pi-hashline-edit-pro 2.8.1 → 2.8.2
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 -4
- package/index.ts +14 -8
- package/package.json +1 -1
- package/prompts/grep-guidelines.md +3 -3
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/src/commit.ts +6 -2
- package/src/constants.ts +1 -1
- package/src/grep.ts +198 -109
- package/src/hash-store/validation.ts +31 -0
- package/src/hash-store.ts +46 -2
- package/src/hashline/apply.ts +3 -3
- package/src/hashline/resolve.ts +52 -36
- package/src/read.ts +5 -3
- package/src/replace-undo.ts +6 -3
- package/src/replace.ts +2 -2
- package/src/served.ts +56 -27
- package/src/write-hook.ts +4 -4
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ Notes:
|
|
|
133
133
|
|
|
134
134
|
## The grep tool
|
|
135
135
|
|
|
136
|
-
`grep` replaces the built-in grep with an anchored search. Every matching line (and each requested context line) is returned as
|
|
136
|
+
`grep` replaces the built-in grep with an anchored search backed by ripgrep. Every matching line (and each requested context line) is returned as `lineNumber │ anchor│content` — the `anchor│content` part is served exactly like `read` output, so you can target it with `replace`/`insert` without a separate `read`, while the line-number gutter and `=== path ===` header give filename and line for navigation (press Return to jump).
|
|
137
137
|
|
|
138
138
|
| Field | Description |
|
|
139
139
|
| --- | --- |
|
|
@@ -146,10 +146,10 @@ Notes:
|
|
|
146
146
|
| `limit` | Maximum number of matched lines to return (default: 100). |
|
|
147
147
|
|
|
148
148
|
Notes:
|
|
149
|
-
- Results are grouped per file under a `=== path ===` header; every shown row
|
|
150
|
-
- Directory searches
|
|
149
|
+
- Results are grouped per file under a `=== path ===` header; every shown row is `lineNumber │ anchor│content` where `anchor│content` is the anchor it would have in `read` output.
|
|
150
|
+
- Directory searches use ripgrep, respecting `.gitignore`; `node_modules`, `.git`, `.tmp`, and `coverage` are always skipped. Binary, image, and oversized files are skipped silently.
|
|
151
151
|
- Regex patterns with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned; use `literal: true` when regex behavior is unnecessary.
|
|
152
|
-
- Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line).
|
|
152
|
+
- Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line).
|
|
153
153
|
- `file_path` works as an alias for `path`.
|
|
154
154
|
- Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
|
|
155
155
|
- Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
|
package/index.ts
CHANGED
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
toggleAutoRead,
|
|
15
15
|
} from "./src/config";
|
|
16
16
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
17
|
-
import { recordServedSafe, clearServed } from "./src/served";
|
|
17
|
+
import { recordServedSafe, clearServed, buildServedMap } from "./src/served";
|
|
18
18
|
import { clearBoundaryBypass } from "./src/boundary-bypass";
|
|
19
19
|
import { registerWriteHook } from "./src/write-hook";
|
|
20
20
|
import { readNormFile } from "./src/file-reader";
|
|
21
21
|
import { loadFileKindAndText } from "./src/file-kind";
|
|
22
22
|
import { resolveInCwd } from "./src/fs-write";
|
|
23
23
|
import { valAccess } from "./src/validation";
|
|
24
|
+
import { splitLines } from "./src/utils";
|
|
24
25
|
|
|
25
26
|
export default function (pi: ExtensionAPI): void {
|
|
26
27
|
regRead(pi);
|
|
@@ -37,12 +38,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
37
38
|
const active = pi.getActiveTools();
|
|
38
39
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
39
40
|
await initHasher();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
loadHashStore()
|
|
42
|
+
.then(store =>
|
|
43
|
+
pruneMissing(store).catch(err => {
|
|
44
|
+
console.error("Failed to prune hash store:", err);
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
.catch(err => {
|
|
48
|
+
console.error("Failed to load hash store:", err);
|
|
49
|
+
});
|
|
46
50
|
const config = await readConfig();
|
|
47
51
|
autoRead = config.autoRead;
|
|
48
52
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
@@ -95,7 +99,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
95
99
|
DEFAULT_MAX_BYTES,
|
|
96
100
|
DEFAULT_MAX_LINES,
|
|
97
101
|
);
|
|
98
|
-
|
|
102
|
+
const fileLines = splitLines(normalized);
|
|
103
|
+
const servedMap = buildServedMap(fileHashes, fileLines, preview.servedHashes);
|
|
104
|
+
await recordServedSafe(absolutePath, servedMap, "auto-read", new Set(fileHashes));
|
|
99
105
|
return {
|
|
100
106
|
content: [
|
|
101
107
|
...(event.content ?? []),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
- `grep`: every hit and `context` line comes back as `anchor│content` —
|
|
2
|
-
- `grep`: use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
|
|
3
|
-
- `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
|
|
1
|
+
- `grep`: every hit and `context` line comes back as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` enables jump-to-line.
|
|
2
|
+
- `grep`: uses ripgrep, respects `.gitignore`; use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
|
|
3
|
+
- `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
|
package/prompts/grep-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search with `anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
|
|
1
|
+
Search with `lineNumber │ anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
|
package/prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files for a pattern. Every hit and each `context` line is returned as `anchor│content`
|
|
1
|
+
Search text files for a pattern using ripgrep. Every hit and each `context` line is returned as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` and the `=== path ===` header give file and line for navigation. Respects `.gitignore` via ripgrep; directory search skips `node_modules/.git/.tmp/coverage` and skips binary/image files. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
|
package/src/commit.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-res
|
|
|
4
4
|
import { saveUndo } from "./replace-undo";
|
|
5
5
|
import { safeSnapId } from "./file-reader";
|
|
6
6
|
import { writeAtomic } from "./fs-write";
|
|
7
|
-
import {
|
|
7
|
+
import { recordServedSafe, buildServedMap, servedHashesFromDiff } from "./served";
|
|
8
8
|
import { restoreEndings } from "./normalize";
|
|
9
|
+
import { splitLines } from "./utils";
|
|
9
10
|
|
|
10
11
|
export interface CommitMeta {
|
|
11
12
|
path: string;
|
|
@@ -102,7 +103,10 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
102
103
|
};
|
|
103
104
|
const changed = buildChanged(successInput, meta.verb);
|
|
104
105
|
if (changed.details.diff) {
|
|
105
|
-
|
|
106
|
+
const diffHashes = servedHashesFromDiff(changed.details.diff);
|
|
107
|
+
const resultLines = splitLines(pipe.result);
|
|
108
|
+
const servedMap = buildServedMap(pipe.resultHashes, resultLines, diffHashes);
|
|
109
|
+
await recordServedSafe(mutationTargetPath, servedMap, "post-edit diff", new Set(pipe.resultHashes));
|
|
106
110
|
}
|
|
107
111
|
return changed;
|
|
108
112
|
}
|
package/src/constants.ts
CHANGED
|
@@ -7,6 +7,6 @@ export const MAX_HASH_SOURCE_BYTES = 500;
|
|
|
7
7
|
export const MAX_GREP_LINE_BYTES = 500;
|
|
8
8
|
|
|
9
9
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
10
|
-
export const HASH_STORE_VERSION =
|
|
10
|
+
export const HASH_STORE_VERSION = 6;
|
|
11
11
|
export const NEW_CONTENT_NOT_ARRAY_MSG =
|
|
12
12
|
`[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
|
package/src/grep.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import {
|
|
4
|
+
import { stat } from "fs/promises";
|
|
5
5
|
import { dirname, join, relative } from "path";
|
|
6
|
+
import { spawn, spawnSync } from "child_process";
|
|
7
|
+
import { createInterface } from "readline";
|
|
6
8
|
import { tryReadNormFile } from "./file-reader";
|
|
7
9
|
import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
|
|
8
10
|
import { MAX_GREP_LINE_BYTES } from "./constants";
|
|
9
11
|
import { toCwd } from "./paths";
|
|
10
12
|
import { loadP, loadGuide } from "./prompts";
|
|
11
13
|
import { normReq } from "./payload-contract";
|
|
12
|
-
import { recordServedSafe } from "./served";
|
|
14
|
+
import { recordServedSafe, buildServedMap } from "./served";
|
|
13
15
|
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
14
16
|
|
|
15
17
|
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
16
|
-
const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
|
|
17
|
-
const MAX_SCAN_FILES = 4000;
|
|
18
18
|
|
|
19
19
|
function cmp(a: string, b: string): number {
|
|
20
20
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
@@ -69,13 +69,11 @@ function unsafeRegex(pattern: string): never {
|
|
|
69
69
|
|
|
70
70
|
function assertSafeRegex(pattern: string): void {
|
|
71
71
|
if (pattern.length > 4096) unsafeRegex(pattern);
|
|
72
|
-
|
|
73
72
|
const groups: RegexGroupRisk[] = [];
|
|
74
73
|
let inClass = false;
|
|
75
74
|
let escaped = false;
|
|
76
75
|
let variableQuantifiers = 0;
|
|
77
76
|
let lastAtom: { groupRisky: boolean; quantified: boolean } | undefined;
|
|
78
|
-
|
|
79
77
|
for (let i = 0; i < pattern.length; i++) {
|
|
80
78
|
const ch = pattern[i]!;
|
|
81
79
|
if (escaped) {
|
|
@@ -122,13 +120,17 @@ function assertSafeRegex(pattern: string): void {
|
|
|
122
120
|
lastAtom = undefined;
|
|
123
121
|
continue;
|
|
124
122
|
}
|
|
125
|
-
|
|
126
123
|
let quantifierLength = 0;
|
|
127
124
|
if (ch === "*" || ch === "+" || ch === "?") {
|
|
128
125
|
quantifierLength = 1;
|
|
129
126
|
} else if (ch === "{") {
|
|
130
127
|
quantifierLength = /^\{\d+(?:,\d*)?\}/.exec(pattern.slice(i))?.[0].length ?? 0;
|
|
131
128
|
}
|
|
129
|
+
if (ch === "{" && quantifierLength > 0) {
|
|
130
|
+
const quant = pattern.slice(i, i + quantifierLength);
|
|
131
|
+
const m = /^\{(\d+)/.exec(quant);
|
|
132
|
+
if (m && Number(m[1]) > 1000) unsafeRegex(pattern);
|
|
133
|
+
}
|
|
132
134
|
if (quantifierLength > 0 && lastAtom) {
|
|
133
135
|
if (ch === "?" && lastAtom.quantified) continue;
|
|
134
136
|
const variable = ch !== "{" || pattern.slice(i, i + quantifierLength).includes(",");
|
|
@@ -140,7 +142,6 @@ function assertSafeRegex(pattern: string): void {
|
|
|
140
142
|
i += quantifierLength - 1;
|
|
141
143
|
continue;
|
|
142
144
|
}
|
|
143
|
-
|
|
144
145
|
lastAtom = { groupRisky: false, quantified: false };
|
|
145
146
|
}
|
|
146
147
|
}
|
|
@@ -177,8 +178,10 @@ interface FileHit {
|
|
|
177
178
|
path: string;
|
|
178
179
|
displayPath: string;
|
|
179
180
|
fileHashes: string[];
|
|
181
|
+
fileLines: string[];
|
|
180
182
|
rows: string[];
|
|
181
183
|
hashes: string[];
|
|
184
|
+
lineNumbers: number[];
|
|
182
185
|
matchCount: number;
|
|
183
186
|
totalMatchCount: number;
|
|
184
187
|
fragmented: boolean[];
|
|
@@ -219,96 +222,41 @@ function grepHeadFragment(line: string): string {
|
|
|
219
222
|
return head.length < line.length ? `${head}...` : head;
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
async function walkFiles(
|
|
228
|
-
root: string,
|
|
229
|
-
state: ScanState,
|
|
230
|
-
onFile: (absPath: string) => Promise<void>,
|
|
231
|
-
signal?: AbortSignal,
|
|
232
|
-
): Promise<void> {
|
|
233
|
-
const queue: string[] = [root];
|
|
234
|
-
let head = 0;
|
|
235
|
-
while (head < queue.length && !state.stopped) {
|
|
236
|
-
abortIf(signal);
|
|
237
|
-
const dir = queue[head++]!;
|
|
238
|
-
let entries;
|
|
239
|
-
try {
|
|
240
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
241
|
-
} catch {
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
entries.sort((a, b) => cmp(a.name, b.name));
|
|
245
|
-
for (let ei = 0; ei < entries.length; ei++) {
|
|
246
|
-
if ((ei & 127) === 0) abortIf(signal);
|
|
247
|
-
if (state.stopped) break;
|
|
248
|
-
const entry = entries[ei]!;
|
|
249
|
-
const full = join(dir, entry.name);
|
|
250
|
-
if (entry.isDirectory()) {
|
|
251
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
252
|
-
queue.push(full);
|
|
253
|
-
} else if (entry.isFile()) {
|
|
254
|
-
state.scanned += 1;
|
|
255
|
-
if (state.scanned > MAX_SCAN_FILES) {
|
|
256
|
-
state.stopped = true;
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
await onFile(full);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
async function searchFile(
|
|
266
|
-
absPath: string,
|
|
267
|
-
globRoot: string,
|
|
268
|
-
cwd: string,
|
|
269
|
-
regex: RegExp,
|
|
270
|
-
globRegex: RegExp | undefined,
|
|
225
|
+
function makeHitFromIndices(
|
|
226
|
+
norm: { normalized: string; fileHashes: string[]; absolutePath: string },
|
|
227
|
+
displayPath: string,
|
|
228
|
+
matchIndices: number[],
|
|
271
229
|
context: number,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (globRegex) {
|
|
277
|
-
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
278
|
-
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) return undefined;
|
|
279
|
-
}
|
|
280
|
-
const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
281
|
-
if (!norm) return undefined;
|
|
230
|
+
regex: RegExp | undefined,
|
|
231
|
+
totalMatchCount: number,
|
|
232
|
+
keptMatchCount: number,
|
|
233
|
+
): FileHit {
|
|
282
234
|
const lines = visLines(norm.normalized);
|
|
283
|
-
const matchLines: number[] = [];
|
|
284
|
-
for (let i = 0; i < lines.length; i++) {
|
|
285
|
-
if ((i & 1023) === 0) abortIf(signal);
|
|
286
|
-
if (i !== 0 && (i & 4095) === 0) await new Promise<void>((r) => setImmediate(r));
|
|
287
|
-
if (regex.test(lines[i]!)) matchLines.push(i);
|
|
288
|
-
}
|
|
289
|
-
if (matchLines.length === 0) return undefined;
|
|
290
|
-
const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
|
|
291
235
|
const shown = new Set<number>();
|
|
292
|
-
|
|
236
|
+
const kept = matchIndices.slice(0, keptMatchCount);
|
|
237
|
+
for (const i of kept) {
|
|
293
238
|
for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
|
|
294
239
|
}
|
|
295
240
|
const sorted = [...shown].sort((a, b) => a - b);
|
|
296
|
-
const matchSet = new Set(
|
|
241
|
+
const matchSet = new Set(matchIndices);
|
|
297
242
|
const rows: string[] = [];
|
|
298
243
|
const hashes: string[] = [];
|
|
244
|
+
const lineNumbers: number[] = [];
|
|
299
245
|
const fragmented: boolean[] = [];
|
|
300
246
|
for (const idx of sorted) {
|
|
301
247
|
const hash = norm.fileHashes[idx]!;
|
|
302
248
|
const line = lines[idx]!;
|
|
303
249
|
const row = fmtRow(hash, line);
|
|
304
250
|
if (Buffer.byteLength(row, "utf-8") > MAX_GREP_LINE_BYTES) {
|
|
305
|
-
const content = matchSet.has(idx) ? grepMatchFragment(line, regex) : grepHeadFragment(line);
|
|
251
|
+
const content = matchSet.has(idx) && regex ? grepMatchFragment(line, regex) : grepHeadFragment(line);
|
|
306
252
|
rows.push(fmtRow(hash, content));
|
|
307
253
|
hashes.push(hash);
|
|
254
|
+
lineNumbers.push(idx + 1);
|
|
308
255
|
fragmented.push(true);
|
|
309
256
|
} else {
|
|
310
257
|
rows.push(row);
|
|
311
258
|
hashes.push(hash);
|
|
259
|
+
lineNumbers.push(idx + 1);
|
|
312
260
|
fragmented.push(false);
|
|
313
261
|
}
|
|
314
262
|
}
|
|
@@ -316,14 +264,142 @@ async function searchFile(
|
|
|
316
264
|
path: norm.absolutePath,
|
|
317
265
|
displayPath,
|
|
318
266
|
fileHashes: norm.fileHashes,
|
|
267
|
+
fileLines: lines,
|
|
319
268
|
rows,
|
|
320
269
|
hashes,
|
|
321
|
-
|
|
322
|
-
|
|
270
|
+
lineNumbers,
|
|
271
|
+
matchCount: kept.length,
|
|
272
|
+
totalMatchCount,
|
|
323
273
|
fragmented,
|
|
324
274
|
};
|
|
325
275
|
}
|
|
326
276
|
|
|
277
|
+
async function resolveRgPath(): Promise<string> {
|
|
278
|
+
try {
|
|
279
|
+
const r = spawnSync("rg", ["--version"], { stdio: "pipe" });
|
|
280
|
+
if (!r.error && r.status === 0) return "rg";
|
|
281
|
+
} catch {}
|
|
282
|
+
try {
|
|
283
|
+
const { homedir } = await import("os");
|
|
284
|
+
const { existsSync } = await import("fs");
|
|
285
|
+
const home = process.env.HOME ?? homedir();
|
|
286
|
+
const base = process.env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent");
|
|
287
|
+
const bin = join(base, "bin", process.platform === "win32" ? "rg.exe" : "rg");
|
|
288
|
+
if (existsSync(bin)) {
|
|
289
|
+
const r = spawnSync(bin, ["--version"], { stdio: "pipe" });
|
|
290
|
+
if (!r.error && r.status === 0) return bin;
|
|
291
|
+
}
|
|
292
|
+
} catch {}
|
|
293
|
+
try {
|
|
294
|
+
const { createRequire } = await import("module");
|
|
295
|
+
const require = createRequire(import.meta.url);
|
|
296
|
+
const pkgPath = require.resolve("@earendil-works/pi-coding-agent/package.json");
|
|
297
|
+
const { dirname } = await import("path");
|
|
298
|
+
const piDir = dirname(pkgPath);
|
|
299
|
+
const toolsManagerPath = join(piDir, "dist/utils/tools-manager.js");
|
|
300
|
+
const mod = await import("file://" + toolsManagerPath);
|
|
301
|
+
if (mod.ensureTool) {
|
|
302
|
+
const p = await mod.ensureTool("rg", true);
|
|
303
|
+
if (p) return p;
|
|
304
|
+
}
|
|
305
|
+
} catch {}
|
|
306
|
+
throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function collectRgMatches(
|
|
310
|
+
rgPath: string,
|
|
311
|
+
pattern: string,
|
|
312
|
+
searchPath: string,
|
|
313
|
+
req: GrepReq,
|
|
314
|
+
signal?: AbortSignal,
|
|
315
|
+
): Promise<Map<string, number[]>> {
|
|
316
|
+
const args = ["--json", "--line-number", "--color=never", "--hidden"];
|
|
317
|
+
if (req.ignoreCase) args.push("--ignore-case");
|
|
318
|
+
if (req.literal) args.push("--fixed-strings");
|
|
319
|
+
args.push("--", pattern, searchPath);
|
|
320
|
+
const result = new Map<string, number[]>();
|
|
321
|
+
return await new Promise<Map<string, number[]>>((resolve, reject) => {
|
|
322
|
+
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
323
|
+
const rl = createInterface({ input: child.stdout });
|
|
324
|
+
let stderr = "";
|
|
325
|
+
let timedOut = false;
|
|
326
|
+
const rgTimeout = setTimeout(() => {
|
|
327
|
+
timedOut = true;
|
|
328
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
329
|
+
reject(new Error("rg timeout"));
|
|
330
|
+
}, 10000);
|
|
331
|
+
child.stderr?.on("data", (chunk) => {
|
|
332
|
+
stderr += chunk.toString();
|
|
333
|
+
});
|
|
334
|
+
const onAbort = () => {
|
|
335
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
336
|
+
};
|
|
337
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
338
|
+
const cleanup = () => {
|
|
339
|
+
clearTimeout(rgTimeout);
|
|
340
|
+
rl.close();
|
|
341
|
+
signal?.removeEventListener("abort", onAbort);
|
|
342
|
+
};
|
|
343
|
+
rl.on("line", (line) => {
|
|
344
|
+
if (!line.trim()) return;
|
|
345
|
+
let event: { type?: string; data?: { path?: { text?: string }; line_number?: number } };
|
|
346
|
+
try {
|
|
347
|
+
event = JSON.parse(line);
|
|
348
|
+
} catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (event.type === "match") {
|
|
352
|
+
const filePath = event.data?.path?.text;
|
|
353
|
+
const lineNumber = event.data?.line_number;
|
|
354
|
+
if (typeof filePath === "string" && typeof lineNumber === "number") {
|
|
355
|
+
let abs: string;
|
|
356
|
+
try {
|
|
357
|
+
abs = filePath.startsWith("/") || /^[A-Za-z]:\\/.test(filePath) ? filePath : join(searchPath, filePath);
|
|
358
|
+
} catch {
|
|
359
|
+
abs = filePath;
|
|
360
|
+
}
|
|
361
|
+
const list = result.get(abs) ?? [];
|
|
362
|
+
list.push(lineNumber);
|
|
363
|
+
result.set(abs, list);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
child.on("error", (error) => {
|
|
368
|
+
cleanup();
|
|
369
|
+
reject(error);
|
|
370
|
+
});
|
|
371
|
+
child.on("close", (code) => {
|
|
372
|
+
cleanup();
|
|
373
|
+
if (timedOut) return;
|
|
374
|
+
if (signal?.aborted) {
|
|
375
|
+
reject(new Error("Operation aborted"));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (code !== 0 && code !== 1) {
|
|
379
|
+
const msg = stderr.trim() || `ripgrep exited with code ${code}`;
|
|
380
|
+
reject(new Error(msg));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
resolve(result);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function gutterWidthFor(numbers: number[]): number {
|
|
389
|
+
let max = 0;
|
|
390
|
+
for (const n of numbers) if (n > max) max = n;
|
|
391
|
+
return String(max || 1).length;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function displayRowsForHit(hit: FileHit): string[] {
|
|
395
|
+
const width = gutterWidthFor(hit.lineNumbers);
|
|
396
|
+
return hit.rows.map((row, i) => {
|
|
397
|
+
const n = hit.lineNumbers[i]!;
|
|
398
|
+
const padded = String(n).padStart(width, " ");
|
|
399
|
+
return `${padded} │ ${row}`;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
327
403
|
const grepToolSchema = Type.Object(
|
|
328
404
|
{
|
|
329
405
|
pattern: Type.String({
|
|
@@ -380,10 +456,8 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
380
456
|
const canonical = normReq(params);
|
|
381
457
|
assertGrepReq(canonical);
|
|
382
458
|
const req = canonical;
|
|
383
|
-
const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
384
459
|
const context = req.context ?? 0;
|
|
385
460
|
const limit = req.limit ?? 100;
|
|
386
|
-
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
387
461
|
const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
|
|
388
462
|
abortIf(signal);
|
|
389
463
|
let baseStat;
|
|
@@ -396,16 +470,9 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
396
470
|
throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
|
|
397
471
|
}
|
|
398
472
|
const globRoot = baseStat.isFile() ? dirname(base) : base;
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
files.push(base);
|
|
403
|
-
} else {
|
|
404
|
-
await walkFiles(base, state, async (absPath) => {
|
|
405
|
-
files.push(absPath);
|
|
406
|
-
}, signal);
|
|
407
|
-
files.sort(cmp);
|
|
408
|
-
}
|
|
473
|
+
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
474
|
+
const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
475
|
+
const rgPath = await resolveRgPath();
|
|
409
476
|
const hits: FileHit[] = [];
|
|
410
477
|
let matches = 0;
|
|
411
478
|
let limitTruncated = false;
|
|
@@ -417,17 +484,26 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
417
484
|
let truncatedBy: "lines" | "bytes" | null = null;
|
|
418
485
|
let linesReplaced = 0;
|
|
419
486
|
let countOnly = false;
|
|
420
|
-
|
|
487
|
+
const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal);
|
|
488
|
+
const sortedFiles = [...rgMatches.keys()].sort(cmp);
|
|
489
|
+
for (let f = 0; f < sortedFiles.length; f++) {
|
|
421
490
|
abortIf(signal);
|
|
422
|
-
const absPath =
|
|
491
|
+
const absPath = sortedFiles[f]!;
|
|
492
|
+
const allNums = rgMatches.get(absPath) ?? [];
|
|
493
|
+
const totalForFile = allNums.length;
|
|
494
|
+
const sortedNums = [...allNums].sort((a, b) => a - b);
|
|
495
|
+
const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
|
|
423
496
|
if (countOnly) {
|
|
424
|
-
const
|
|
425
|
-
if (!
|
|
426
|
-
|
|
427
|
-
|
|
497
|
+
const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
498
|
+
if (!norm) continue;
|
|
499
|
+
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
|
|
500
|
+
const display = displayRowsForHit(hit);
|
|
501
|
+
totalRows += display.length;
|
|
502
|
+
for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
|
|
428
503
|
const remaining = limit - matches;
|
|
429
504
|
if (remaining > 0) {
|
|
430
|
-
|
|
505
|
+
const add = Math.min(hit.matchCount, remaining);
|
|
506
|
+
matches += add;
|
|
431
507
|
if (hit.matchCount > remaining) limitTruncated = true;
|
|
432
508
|
} else {
|
|
433
509
|
limitTruncated = true;
|
|
@@ -439,24 +515,36 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
439
515
|
limitTruncated = true;
|
|
440
516
|
break;
|
|
441
517
|
}
|
|
442
|
-
const
|
|
518
|
+
const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
519
|
+
if (!norm) continue;
|
|
520
|
+
if (globRegex) {
|
|
521
|
+
const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
|
|
522
|
+
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
523
|
+
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
|
|
524
|
+
}
|
|
525
|
+
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
|
|
443
526
|
if (!hit) continue;
|
|
527
|
+
const display = displayRowsForHit(hit);
|
|
444
528
|
const keptRows: string[] = [];
|
|
445
529
|
const keptHashes: string[] = [];
|
|
446
|
-
|
|
447
|
-
|
|
530
|
+
const keptLineNumbers: number[] = [];
|
|
531
|
+
const keptFragmented: boolean[] = [];
|
|
532
|
+
for (let i = 0; i < display.length; i++) {
|
|
533
|
+
const row = display[i]!;
|
|
448
534
|
const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
|
|
449
535
|
if (rowCount >= DEFAULT_MAX_LINES || byteCount + rowBytes > DEFAULT_MAX_BYTES) {
|
|
450
536
|
rowTruncated = true;
|
|
451
537
|
if (truncatedBy === null) truncatedBy = byteCount + rowBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines";
|
|
452
|
-
for (let j = i; j <
|
|
538
|
+
for (let j = i; j < display.length; j++) {
|
|
453
539
|
totalRows += 1;
|
|
454
|
-
totalBytes += Buffer.byteLength(
|
|
540
|
+
totalBytes += Buffer.byteLength(display[j]!, "utf-8") + 1;
|
|
455
541
|
}
|
|
456
542
|
break;
|
|
457
543
|
}
|
|
458
544
|
keptRows.push(row);
|
|
459
|
-
keptHashes.push(hit.hashes[i]);
|
|
545
|
+
keptHashes.push(hit.hashes[i]!);
|
|
546
|
+
keptLineNumbers.push(hit.lineNumbers[i]!);
|
|
547
|
+
keptFragmented.push(hit.fragmented[i]!);
|
|
460
548
|
if (hit.fragmented[i]) linesReplaced += 1;
|
|
461
549
|
rowCount += 1;
|
|
462
550
|
byteCount += rowBytes;
|
|
@@ -465,12 +553,14 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
465
553
|
}
|
|
466
554
|
if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
|
|
467
555
|
matches += hit.matchCount;
|
|
468
|
-
|
|
556
|
+
const displayHit: FileHit = { ...hit, rows: keptRows, hashes: keptHashes, lineNumbers: keptLineNumbers, fragmented: keptFragmented };
|
|
557
|
+
hits.push(displayHit);
|
|
469
558
|
if (rowTruncated) countOnly = true;
|
|
470
559
|
}
|
|
471
560
|
hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
|
|
472
561
|
for (const hit of hits) {
|
|
473
|
-
|
|
562
|
+
const servedMap = buildServedMap(hit.fileHashes, hit.fileLines, hit.hashes);
|
|
563
|
+
await recordServedSafe(hit.path, servedMap, "grep", new Set(hit.fileHashes));
|
|
474
564
|
}
|
|
475
565
|
const blocks = hits
|
|
476
566
|
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
|
@@ -478,7 +568,6 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
478
568
|
const notes: string[] = [];
|
|
479
569
|
if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
|
|
480
570
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
481
|
-
if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
|
|
482
571
|
if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
|
|
483
572
|
const truncated = limitTruncated || rowTruncated;
|
|
484
573
|
const truncation: TruncationResult | undefined = rowTruncated
|
|
@@ -505,7 +594,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
505
594
|
metrics: {
|
|
506
595
|
matches,
|
|
507
596
|
files: hits.length,
|
|
508
|
-
truncated
|
|
597
|
+
truncated,
|
|
509
598
|
},
|
|
510
599
|
},
|
|
511
600
|
};
|
|
@@ -9,6 +9,15 @@ export function isValidHashList(value: unknown): value is string[] {
|
|
|
9
9
|
return true;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function isValidServedMap(value: unknown): value is Record<string, string> {
|
|
13
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
14
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
15
|
+
if (typeof k !== "string" || !HASH_RE.test(k)) return false;
|
|
16
|
+
if (typeof v !== "string") return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
|
|
13
22
|
let parsed: unknown;
|
|
14
23
|
try {
|
|
@@ -26,11 +35,33 @@ export function parseHashList(raw: string, onInvalid: () => void, context?: stri
|
|
|
26
35
|
return parsed;
|
|
27
36
|
}
|
|
28
37
|
|
|
38
|
+
export function parseServedMap(raw: string, onInvalid: () => void, context?: string): Map<string, string> | undefined {
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`[parseServedMap]${context ? ` ${context}:` : ""} failed to parse stored served JSON:`, error);
|
|
44
|
+
onInvalid();
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (!isValidServedMap(parsed)) {
|
|
48
|
+
console.error(`[parseServedMap]${context ? ` ${context}:` : ""} stored served did not pass validation:`, (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
|
|
49
|
+
onInvalid();
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return new Map(Object.entries(parsed as Record<string, string>));
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
export function parseStoredHashes(row: Record<string, unknown> | undefined, onInvalid: () => void): string[] | undefined {
|
|
30
56
|
if (!row) return undefined;
|
|
31
57
|
return parseHashList(row.hashes as string, onInvalid);
|
|
32
58
|
}
|
|
33
59
|
|
|
60
|
+
export function parseStoredServed(row: Record<string, unknown> | undefined, onInvalid: () => void): Map<string, string> | undefined {
|
|
61
|
+
if (!row) return undefined;
|
|
62
|
+
return parseServedMap(row.hashes as string, onInvalid);
|
|
63
|
+
}
|
|
64
|
+
|
|
34
65
|
export function isValidSnapshot(value: unknown): value is { content: string; hashes: string[] } {
|
|
35
66
|
if (typeof value !== "object" || value === null) return false;
|
|
36
67
|
const v = value as Record<string, unknown>;
|
package/src/hash-store.ts
CHANGED
|
@@ -6,10 +6,13 @@ import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
|
6
6
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
7
7
|
import {
|
|
8
8
|
isValidHashList,
|
|
9
|
+
isValidServedMap,
|
|
9
10
|
parseStoredHashes,
|
|
11
|
+
parseStoredServed,
|
|
10
12
|
isValidSnapshot,
|
|
11
13
|
isCorruptionError,
|
|
12
14
|
parseHashList,
|
|
15
|
+
parseServedMap,
|
|
13
16
|
} from "./hash-store/validation";
|
|
14
17
|
import {
|
|
15
18
|
withBusyRetry,
|
|
@@ -22,7 +25,7 @@ import {
|
|
|
22
25
|
SNAPSHOT_CACHE_LIMIT,
|
|
23
26
|
} from "./hash-store/cache";
|
|
24
27
|
|
|
25
|
-
export { isValidHashList, parseHashList, parseStoredHashes, isCorruptionError };
|
|
28
|
+
export { isValidHashList, isValidServedMap, parseHashList, parseServedMap, parseStoredHashes, parseStoredServed, isCorruptionError };
|
|
26
29
|
export { SNAPSHOT_CACHE_LIMIT };
|
|
27
30
|
export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
|
|
28
31
|
|
|
@@ -272,6 +275,20 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
272
275
|
opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
|
|
273
276
|
}
|
|
274
277
|
const { db, stmts } = opened;
|
|
278
|
+
try {
|
|
279
|
+
const autoVacuum = (db.prepare("PRAGMA auto_vacuum").get() as { auto_vacuum: number }).auto_vacuum;
|
|
280
|
+
const pageCount = (db.prepare("PRAGMA page_count").get() as { page_count: number }).page_count;
|
|
281
|
+
const freelist = (db.prepare("PRAGMA freelist_count").get() as { freelist_count: number }).freelist_count;
|
|
282
|
+
if (autoVacuum === 0 && !existed) {
|
|
283
|
+
db.exec("PRAGMA auto_vacuum=INCREMENTAL");
|
|
284
|
+
} else if (freelist > 50 && freelist * 5 > pageCount) {
|
|
285
|
+
try {
|
|
286
|
+
db.exec("PRAGMA incremental_vacuum(50)");
|
|
287
|
+
} catch {
|
|
288
|
+
db.exec("VACUUM");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch {}
|
|
275
292
|
|
|
276
293
|
if (process.platform !== "win32") {
|
|
277
294
|
for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
|
|
@@ -551,10 +568,37 @@ function matchPathsByHashes(
|
|
|
551
568
|
return matches;
|
|
552
569
|
}
|
|
553
570
|
|
|
571
|
+
function matchPathsByServed(
|
|
572
|
+
rows: { path: string; hashes: string }[],
|
|
573
|
+
hashes: string[],
|
|
574
|
+
): string[] {
|
|
575
|
+
const needed = new Set(hashes);
|
|
576
|
+
if (needed.size === 0) return [];
|
|
577
|
+
const matches: string[] = [];
|
|
578
|
+
for (const row of rows) {
|
|
579
|
+
try {
|
|
580
|
+
const parsed = JSON.parse(row.hashes) as unknown;
|
|
581
|
+
if (!isValidServedMap(parsed)) continue;
|
|
582
|
+
const keySet = new Set(Object.keys(parsed as Record<string, unknown>));
|
|
583
|
+
let ok = true;
|
|
584
|
+
for (const h of needed) {
|
|
585
|
+
if (!keySet.has(h)) {
|
|
586
|
+
ok = false;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (ok) matches.push(row.path);
|
|
591
|
+
} catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return matches;
|
|
596
|
+
}
|
|
597
|
+
|
|
554
598
|
export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
|
|
555
599
|
return matchPathsByHashes(store.stmts.allHashes() as { path: string; hashes: string }[], hashes);
|
|
556
600
|
}
|
|
557
601
|
|
|
558
602
|
export function findServedPaths(store: HashStore, hashes: string[]): string[] {
|
|
559
|
-
return
|
|
603
|
+
return matchPathsByServed(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
|
|
560
604
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -154,7 +154,7 @@ export function applyEdit(
|
|
|
154
154
|
signal?: AbortSignal,
|
|
155
155
|
precomputedHashes?: string[],
|
|
156
156
|
filePath?: string,
|
|
157
|
-
servedHashes?:
|
|
157
|
+
servedHashes?: ReadonlyMap<string, string>,
|
|
158
158
|
skipBoundaryDedup?: boolean,
|
|
159
159
|
): {
|
|
160
160
|
content: string;
|
|
@@ -190,7 +190,7 @@ export function applyEdit(
|
|
|
190
190
|
fileHashes,
|
|
191
191
|
filePath,
|
|
192
192
|
);
|
|
193
|
-
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
193
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
warnUnicodeEsc(prefixFixed, warnings);
|
|
@@ -233,7 +233,7 @@ export function applyEdit(
|
|
|
233
233
|
fileHashes,
|
|
234
234
|
filePath,
|
|
235
235
|
);
|
|
236
|
-
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
236
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
237
237
|
}
|
|
238
238
|
resolved = correctedResult.resolved;
|
|
239
239
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, cl
|
|
|
2
2
|
import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
4
|
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
|
+
import { contentChecksum } from "./hasher";
|
|
5
6
|
|
|
6
7
|
export type RAnchor = {
|
|
7
8
|
line: number;
|
|
@@ -90,14 +91,13 @@ export function fmtMismatchWithHashes(
|
|
|
90
91
|
fileLines: string[],
|
|
91
92
|
fileHashes: string[],
|
|
92
93
|
filePath?: string,
|
|
93
|
-
): { text: string; hashes: string[] } {
|
|
94
|
+
): { text: string; hashes: string[]; servedMap: Map<string, string> } {
|
|
94
95
|
assertAligned(fileLines, fileHashes, "fmtMismatch");
|
|
95
|
-
|
|
96
96
|
const out: string[] = [];
|
|
97
97
|
const hashes: string[] = [];
|
|
98
|
+
const servedMap = new Map<string, string>();
|
|
98
99
|
const notFound = mismatches.filter((m) => m.kind === "not_found");
|
|
99
100
|
const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
|
|
100
|
-
|
|
101
101
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
102
102
|
if (notFound.length > 0) {
|
|
103
103
|
out.push(
|
|
@@ -110,8 +110,11 @@ export function fmtMismatchWithHashes(
|
|
|
110
110
|
const to = Math.min(fileLines.length, ctx.line + 1);
|
|
111
111
|
const rows: string[] = [];
|
|
112
112
|
for (let ln = from; ln <= to; ln++) {
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
const h = fileHashes[ln - 1]!;
|
|
114
|
+
const c = fileLines[ln - 1] ?? "";
|
|
115
|
+
hashes.push(h);
|
|
116
|
+
servedMap.set(h, contentChecksum(c));
|
|
117
|
+
rows.push(` ${ln}: ${h}│${clipLine(c)}`);
|
|
115
118
|
}
|
|
116
119
|
out.push("");
|
|
117
120
|
out.push(` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`);
|
|
@@ -128,7 +131,12 @@ export function fmtMismatchWithHashes(
|
|
|
128
131
|
(m.candidates?.length ?? 0) > sample.length
|
|
129
132
|
? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
|
|
130
133
|
: "";
|
|
131
|
-
for (const line of sample)
|
|
134
|
+
for (const line of sample) {
|
|
135
|
+
const h = fileHashes[line - 1]!;
|
|
136
|
+
const c = fileLines[line - 1] ?? "";
|
|
137
|
+
hashes.push(h);
|
|
138
|
+
servedMap.set(h, contentChecksum(c));
|
|
139
|
+
}
|
|
132
140
|
const lines = sample
|
|
133
141
|
.map((line) => {
|
|
134
142
|
const content = clipLine(fileLines[line - 1] ?? "");
|
|
@@ -140,8 +148,7 @@ export function fmtMismatchWithHashes(
|
|
|
140
148
|
);
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
|
-
|
|
144
|
-
return { text: out.join("\n"), hashes };
|
|
151
|
+
return { text: out.join("\n"), hashes, servedMap };
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
|
|
@@ -482,45 +489,49 @@ export function valEdit(
|
|
|
482
489
|
}
|
|
483
490
|
|
|
484
491
|
export function resolveAnchorLine(
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
492
|
+
ref: Anchor,
|
|
493
|
+
fileLines: string[],
|
|
494
|
+
fileHashes: string[],
|
|
495
|
+
filePath?: string,
|
|
489
496
|
): number {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
497
|
+
const { resolved, mismatches } = valEdit(
|
|
498
|
+
{ hash_bounds: [ref, ref], content_lines: [] },
|
|
499
|
+
fileLines,
|
|
500
|
+
fileHashes,
|
|
501
|
+
[],
|
|
502
|
+
undefined,
|
|
503
|
+
);
|
|
504
|
+
if (mismatches.length > 0 || !resolved) {
|
|
505
|
+
const feedback = fmtMismatchWithHashes(
|
|
506
|
+
mismatches,
|
|
507
|
+
fileLines,
|
|
508
|
+
fileHashes,
|
|
509
|
+
filePath,
|
|
510
|
+
);
|
|
511
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
512
|
+
}
|
|
513
|
+
return resolved.hash_bounds[0].line;
|
|
507
514
|
}
|
|
508
515
|
|
|
509
516
|
export class RangeStaleError extends Error {
|
|
510
517
|
readonly rangeHashes: string[];
|
|
511
|
-
|
|
518
|
+
readonly rangeServedMap: Map<string, string>;
|
|
519
|
+
constructor(message: string, rangeHashes: string[], rangeServedMap: Map<string, string>) {
|
|
512
520
|
super(message);
|
|
513
521
|
this.name = "RangeStaleError";
|
|
514
522
|
this.rangeHashes = rangeHashes;
|
|
523
|
+
this.rangeServedMap = rangeServedMap;
|
|
515
524
|
}
|
|
516
525
|
}
|
|
517
526
|
|
|
518
527
|
export class AnchorMismatchError extends Error {
|
|
519
528
|
readonly feedbackHashes: string[];
|
|
520
|
-
|
|
529
|
+
readonly feedbackMap: Map<string, string>;
|
|
530
|
+
constructor(message: string, feedbackHashes: string[], feedbackMap: Map<string, string>) {
|
|
521
531
|
super(message);
|
|
522
532
|
this.name = "AnchorMismatchError";
|
|
523
533
|
this.feedbackHashes = feedbackHashes;
|
|
534
|
+
this.feedbackMap = feedbackMap;
|
|
524
535
|
}
|
|
525
536
|
}
|
|
526
537
|
|
|
@@ -528,7 +539,7 @@ export function assertRangeServed(
|
|
|
528
539
|
resolved: RHEdit,
|
|
529
540
|
fileLines: string[],
|
|
530
541
|
fileHashes: string[],
|
|
531
|
-
served:
|
|
542
|
+
served: ReadonlyMap<string, string> | undefined,
|
|
532
543
|
filePath?: string,
|
|
533
544
|
): void {
|
|
534
545
|
assertAligned(fileLines, fileHashes, "assertRangeServed");
|
|
@@ -536,18 +547,23 @@ export function assertRangeServed(
|
|
|
536
547
|
const endLine = resolved.hash_bounds[1].line;
|
|
537
548
|
const mismatchLines: number[] = [];
|
|
538
549
|
for (let line = startLine; line <= endLine; line++) {
|
|
539
|
-
|
|
550
|
+
const hash = fileHashes[line - 1]!;
|
|
551
|
+
const content = fileLines[line - 1]!;
|
|
552
|
+
const servedContent = served?.get(hash);
|
|
553
|
+
if (servedContent === undefined || servedContent !== contentChecksum(content)) mismatchLines.push(line);
|
|
540
554
|
}
|
|
541
555
|
if (mismatchLines.length === 0) return;
|
|
542
|
-
|
|
543
556
|
const rangeLength = endLine - startLine + 1;
|
|
544
557
|
const shownLength = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
|
|
545
558
|
const rows: string[] = [];
|
|
546
559
|
const shownHashes: string[] = [];
|
|
560
|
+
const shownMap = new Map<string, string>();
|
|
547
561
|
for (let line = startLine; line < startLine + shownLength; line++) {
|
|
548
562
|
const hash = fileHashes[line - 1]!;
|
|
563
|
+
const content = fileLines[line - 1]!;
|
|
549
564
|
shownHashes.push(hash);
|
|
550
|
-
|
|
565
|
+
shownMap.set(hash, contentChecksum(content));
|
|
566
|
+
rows.push(fmtRow(hash, clipLine(content)));
|
|
551
567
|
}
|
|
552
568
|
const location = filePath ? ` in ${filePath}` : "";
|
|
553
569
|
const first = mismatchLines[0]!;
|
|
@@ -561,7 +577,7 @@ export function assertRangeServed(
|
|
|
561
577
|
: "";
|
|
562
578
|
const message =
|
|
563
579
|
`[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
564
|
-
throw new RangeStaleError(message, shownHashes);
|
|
580
|
+
throw new RangeStaleError(message, shownHashes, shownMap);
|
|
565
581
|
}
|
|
566
582
|
|
|
567
583
|
export { warnUnicodeEsc };
|
package/src/read.ts
CHANGED
|
@@ -13,8 +13,8 @@ import { MAX_OVERSIZED_WARNING_LINES } from "./constants";
|
|
|
13
13
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
14
14
|
import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
15
15
|
import { toCwd } from "./paths";
|
|
16
|
-
import { abortIf, makePrepareArguments, numberedRead, visLines } from "./utils";
|
|
17
|
-
import { recordServedSafe } from "./served";
|
|
16
|
+
import { abortIf, makePrepareArguments, numberedRead, visLines, splitLines } from "./utils";
|
|
17
|
+
import { recordServedSafe, buildServedMap } from "./served";
|
|
18
18
|
import { loadP, loadGuide } from "./prompts";
|
|
19
19
|
import { valAccess } from "./validation";
|
|
20
20
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -236,7 +236,9 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
236
236
|
fileHashes,
|
|
237
237
|
resolvedPath,
|
|
238
238
|
);
|
|
239
|
-
|
|
239
|
+
const fileLines = splitLines(normalized);
|
|
240
|
+
const servedMap = buildServedMap(fileHashes, fileLines, preview.servedHashes);
|
|
241
|
+
await recordServedSafe(resolvedPath, servedMap, "read", new Set(fileHashes));
|
|
240
242
|
const snapshotId = await safeSnapId(absolutePath, "read");
|
|
241
243
|
const previewText =
|
|
242
244
|
hadUtf8DecodeErrors
|
package/src/replace-undo.ts
CHANGED
|
@@ -4,11 +4,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
7
|
-
import {
|
|
7
|
+
import { recordServed, buildServedMap, servedHashesFromDiff } from "./served";
|
|
8
8
|
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
9
9
|
import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
|
|
10
10
|
import { genDiff, genPatch } from "./replace-diff";
|
|
11
|
-
import { cntDiff, errCode, makePrepareArguments } from "./utils";
|
|
11
|
+
import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { buildMetrics } from "./replace-response";
|
|
14
14
|
import { renderEditResult } from "./replace-render";
|
|
@@ -172,7 +172,10 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
172
172
|
try {
|
|
173
173
|
const store = await loadHashStore();
|
|
174
174
|
persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes);
|
|
175
|
-
|
|
175
|
+
const diffHashes = servedHashesFromDiff(undoDiff);
|
|
176
|
+
const undoLines = splitLines(undo.content);
|
|
177
|
+
const servedMap = buildServedMap(undo.hashes, undoLines, diffHashes);
|
|
178
|
+
recordServed(store, mutationTargetPath, servedMap, new Set(undo.hashes));
|
|
176
179
|
} catch (error) {
|
|
177
180
|
console.error("Failed to restore hash store snapshot after undo:", error);
|
|
178
181
|
}
|
package/src/replace.ts
CHANGED
|
@@ -119,8 +119,8 @@ function hashSpan(hashes: string[], from: string, to: string): [number, number]
|
|
|
119
119
|
}
|
|
120
120
|
async function noteAnchorError(absolutePath: string, error: unknown, scopeHashes: string[], noPersist?: boolean): Promise<void> {
|
|
121
121
|
if (noPersist === true) return;
|
|
122
|
-
if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.
|
|
123
|
-
else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.
|
|
122
|
+
if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeServedMap, "range-stale feedback", new Set(scopeHashes));
|
|
123
|
+
else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackMap, "anchor-mismatch feedback", new Set(scopeHashes));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
function collectRemovedHashes(
|
package/src/served.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { loadHashStore,
|
|
1
|
+
import { loadHashStore, parseStoredServed, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
|
|
2
2
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
|
+
import { contentChecksum } from "./hashline/hasher";
|
|
3
4
|
|
|
4
5
|
const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
|
|
5
6
|
|
|
@@ -12,60 +13,88 @@ export function servedHashesFromDiff(diff: string): string[] {
|
|
|
12
13
|
return hashes;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export function
|
|
16
|
+
export function servedMapFromDiff(diff: string): Map<string, string> {
|
|
17
|
+
const map = new Map<string, string>();
|
|
18
|
+
for (const line of diff.split("\n")) {
|
|
19
|
+
const match = SERVED_DIFF_ROW_RE.exec(line);
|
|
20
|
+
if (!match) continue;
|
|
21
|
+
const hash = match[1]!;
|
|
22
|
+
const content = line.slice(match[0].length);
|
|
23
|
+
map.set(hash, contentChecksum(content));
|
|
24
|
+
}
|
|
25
|
+
return map;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildServedMap(fileHashes: string[], fileLines: string[], wantedHashes: string[]): Map<string, string> {
|
|
29
|
+
const index = new Map<string, number>();
|
|
30
|
+
for (let i = 0; i < fileHashes.length; i++) index.set(fileHashes[i]!, i);
|
|
31
|
+
const map = new Map<string, string>();
|
|
32
|
+
for (const h of wantedHashes) {
|
|
33
|
+
const idx = index.get(h);
|
|
34
|
+
if (idx !== undefined) map.set(h, contentChecksum(fileLines[idx]!));
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getServed(store: HashStore, path: string): Map<string, string> | undefined {
|
|
16
40
|
const row = store.stmts.servedGet(path);
|
|
17
|
-
const parsed =
|
|
41
|
+
const parsed = parseStoredServed(row, () => store.stmts.servedDelete(path));
|
|
18
42
|
if (!parsed) return undefined;
|
|
19
|
-
return
|
|
43
|
+
return parsed;
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
function computeUpdate(
|
|
23
47
|
store: HashStore,
|
|
24
48
|
path: string,
|
|
25
|
-
|
|
49
|
+
entries: Map<string, string>,
|
|
26
50
|
scope?: ReadonlySet<string>,
|
|
27
|
-
):
|
|
51
|
+
): Map<string, string> | undefined {
|
|
28
52
|
const existing = getServed(store, path);
|
|
29
|
-
if (!existing &&
|
|
30
|
-
const
|
|
53
|
+
if (!existing && entries.size === 0 && !scope) return undefined;
|
|
54
|
+
const map = existing ? new Map(existing) : new Map<string, string>();
|
|
31
55
|
let changed = false;
|
|
32
56
|
if (scope) {
|
|
33
|
-
for (const hash of
|
|
57
|
+
for (const hash of [...map.keys()]) {
|
|
34
58
|
if (!scope.has(hash)) {
|
|
35
|
-
|
|
59
|
+
map.delete(hash);
|
|
36
60
|
changed = true;
|
|
37
61
|
}
|
|
38
62
|
}
|
|
39
63
|
}
|
|
40
|
-
for (const hash of
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
for (const [hash, content] of entries) {
|
|
65
|
+
const prev = map.get(hash);
|
|
66
|
+
if (prev !== content) {
|
|
67
|
+
map.set(hash, content);
|
|
43
68
|
changed = true;
|
|
44
69
|
}
|
|
45
70
|
}
|
|
46
|
-
if (!changed) return undefined;
|
|
47
|
-
return
|
|
71
|
+
if (!changed && existing) return undefined;
|
|
72
|
+
if (map.size === 0 && (!existing || existing.size === 0)) return undefined;
|
|
73
|
+
if (!changed) return existing;
|
|
74
|
+
return map;
|
|
48
75
|
}
|
|
49
76
|
|
|
50
77
|
export function recordServed(
|
|
51
78
|
store: HashStore,
|
|
52
79
|
path: string,
|
|
53
|
-
|
|
80
|
+
entries: Map<string, string>,
|
|
54
81
|
scope?: ReadonlySet<string>,
|
|
55
82
|
): void {
|
|
56
83
|
try {
|
|
57
84
|
withStore(() => {
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
60
|
-
|
|
85
|
+
const map = computeUpdate(store, path, entries, scope);
|
|
86
|
+
if (!map) return;
|
|
87
|
+
const obj = Object.fromEntries(map);
|
|
88
|
+
store.stmts.servedUpsert(path, JSON.stringify(obj), Date.now());
|
|
61
89
|
});
|
|
62
90
|
return;
|
|
63
91
|
} catch (error) {
|
|
64
92
|
if (!(error instanceof Error && error.message === STORE_NOT_OPEN_MESSAGE)) throw error;
|
|
65
93
|
}
|
|
66
|
-
const
|
|
67
|
-
if (!
|
|
68
|
-
|
|
94
|
+
const map = computeUpdate(store, path, entries, scope);
|
|
95
|
+
if (!map) return;
|
|
96
|
+
const obj = Object.fromEntries(map);
|
|
97
|
+
store.stmts.servedUpsert(path, JSON.stringify(obj), Date.now());
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
export function recordServedDiff(
|
|
@@ -74,7 +103,7 @@ export function recordServedDiff(
|
|
|
74
103
|
diff: string,
|
|
75
104
|
scope?: ReadonlySet<string>,
|
|
76
105
|
): void {
|
|
77
|
-
recordServed(store, path,
|
|
106
|
+
recordServed(store, path, servedMapFromDiff(diff), scope);
|
|
78
107
|
}
|
|
79
108
|
|
|
80
109
|
export function clearServed(store: HashStore, path: string): void {
|
|
@@ -83,14 +112,14 @@ export function clearServed(store: HashStore, path: string): void {
|
|
|
83
112
|
|
|
84
113
|
export async function recordServedSafe(
|
|
85
114
|
path: string,
|
|
86
|
-
|
|
115
|
+
entries: Map<string, string>,
|
|
87
116
|
context: string,
|
|
88
117
|
scope?: ReadonlySet<string>,
|
|
89
118
|
): Promise<void> {
|
|
90
|
-
if (
|
|
119
|
+
if (entries.size === 0 && !scope) return;
|
|
91
120
|
try {
|
|
92
121
|
const store = await loadHashStore();
|
|
93
|
-
recordServed(store, path,
|
|
122
|
+
recordServed(store, path, entries, scope);
|
|
94
123
|
} catch (error) {
|
|
95
124
|
console.error(`Failed to record served state (${context}):`, error);
|
|
96
125
|
}
|
|
@@ -103,5 +132,5 @@ export async function recordServedDiffSafe(
|
|
|
103
132
|
scope?: ReadonlySet<string>,
|
|
104
133
|
): Promise<void> {
|
|
105
134
|
if (!diff) return;
|
|
106
|
-
await recordServedSafe(path,
|
|
135
|
+
await recordServedSafe(path, servedMapFromDiff(diff), context, scope);
|
|
107
136
|
}
|
package/src/write-hook.ts
CHANGED
|
@@ -8,19 +8,19 @@ import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
|
|
|
8
8
|
|
|
9
9
|
const HASH_ECHO_RE = new RegExp(`^(${HASH_CLASS})${HASH_SEP}`);
|
|
10
10
|
|
|
11
|
-
function searchEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
11
|
+
function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
12
12
|
for (let i = 0; i < lines.length; i++) {
|
|
13
13
|
const match = HASH_ECHO_RE.exec(lines[i]!);
|
|
14
|
-
if (match && served.has(match[1]!)) return { line: i + 1, hash: match[1]! };
|
|
14
|
+
if (match && served.has(match[1]! as never)) return { line: i + 1, hash: match[1]! };
|
|
15
15
|
}
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function findServedHashEcho(content: string, served: ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
19
|
+
export function findServedHashEcho(content: string, served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
20
20
|
return searchEcho(splitLines(content), served);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function findEditHashEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
23
|
+
export function findEditHashEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
|
|
24
24
|
return searchEcho(lines, served);
|
|
25
25
|
}
|
|
26
26
|
|