pi-hashline-edit-pro 3.0.4 → 4.0.0
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 +111 -129
- package/index.ts +22 -11
- package/package.json +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +1 -2
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +0 -1
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/anchor-registry.ts +605 -0
- package/src/commit.ts +21 -7
- package/src/constants.ts +4 -1
- package/src/edit-common.ts +45 -8
- package/src/file-reader.ts +4 -1
- package/src/grep.ts +26 -8
- package/src/hash-store/cache.ts +0 -4
- package/src/hash-store.ts +50 -137
- package/src/hashline/anchor-table.json +1 -1
- package/src/hashline/apply.ts +4 -1
- package/src/hashline/hash.ts +12 -175
- package/src/hashline/parse.ts +1 -1
- package/src/hashline/resolve.ts +11 -43
- package/src/insert.ts +16 -31
- package/src/paths.ts +4 -0
- package/src/payload-contract.ts +3 -13
- package/src/read.ts +4 -5
- package/src/replace-render.ts +11 -2
- package/src/replace-undo.ts +15 -5
- package/src/replace.ts +26 -42
- package/src/served.ts +14 -121
- package/src/write-hook.ts +2 -4
- package/src/missing-path.ts +0 -54
package/src/edit-common.ts
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { resolveInCwd } from "./fs-write";
|
|
3
3
|
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
|
+
import { ownerOf } from "./anchor-registry";
|
|
5
|
+
import { parseHashRef, stripAnchorRow } from "./hashline";
|
|
4
6
|
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
5
7
|
import type { ReplaceDetails } from "./replace";
|
|
6
8
|
|
|
7
9
|
export const editPrepare = makePrepareArguments();
|
|
8
10
|
|
|
9
|
-
export function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
|
|
12
|
+
const refs = [removeFrom, removeTo].filter((value): value is string => typeof value === "string");
|
|
13
|
+
const owners = refs.map((ref) => ownerOf(parseHashRef(stripAnchorRow(ref.trim(), "anchor entry")).hash));
|
|
14
|
+
const missing = owners.findIndex((owner) => !owner);
|
|
15
|
+
if (missing >= 0) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`[E_STALE_ANCHOR] "${refs[missing]!}" is not owned in this session. Call read() on the target file first.`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
const paths = new Set(owners.map((owner) => owner!.path));
|
|
21
|
+
if (paths.size > 1) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`[E_BAD_SHAPE] The anchors are owned by different files (${owners.map((owner) => owner!.path).join(", ")}); edit one file per call.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return owners[0]!.path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function tryResolveEditTarget(removeFrom: string | undefined, removeTo?: string): string | undefined {
|
|
30
|
+
if (typeof removeFrom !== "string") return undefined;
|
|
31
|
+
try {
|
|
32
|
+
return resolveEditTarget(removeFrom, removeTo);
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
16
36
|
}
|
|
17
37
|
|
|
18
38
|
export function editRenderCallWrapper(
|
|
@@ -20,7 +40,24 @@ export function editRenderCallWrapper(
|
|
|
20
40
|
getInput?: (args: unknown) => { path?: string } | null,
|
|
21
41
|
toolName?: string,
|
|
22
42
|
) {
|
|
23
|
-
return makeRenderCall(preview, {
|
|
43
|
+
return makeRenderCall(preview, {
|
|
44
|
+
getInput,
|
|
45
|
+
toolName,
|
|
46
|
+
resolveTarget: (input) => {
|
|
47
|
+
if (typeof input.remove_from === "string") return tryResolveEditTarget(input.remove_from, input.remove_to);
|
|
48
|
+
if (typeof input.anchor === "string") return tryResolveEditTarget(input.anchor);
|
|
49
|
+
return undefined;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function editRenderResultWrapper(
|
|
55
|
+
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
56
|
+
opts: { isPartial: boolean; expanded?: boolean } | boolean,
|
|
57
|
+
theme: FgT,
|
|
58
|
+
context: any,
|
|
59
|
+
) {
|
|
60
|
+
return renderEditResult(result, opts, theme, context);
|
|
24
61
|
}
|
|
25
62
|
|
|
26
63
|
export const editToolBase = {
|
package/src/file-reader.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { resolveTarget, type FileIdentity } from "./fs-write";
|
|
|
7
7
|
import { toCwd } from "./paths";
|
|
8
8
|
import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
|
|
9
9
|
import { abortIf, errCode, assertLineLimit } from "./utils";
|
|
10
|
+
import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
|
|
10
11
|
import { valKind, valAccess } from "./validation";
|
|
11
12
|
import type { HashStore } from "./hash-store";
|
|
12
13
|
export interface NormFile {
|
|
@@ -66,6 +67,7 @@ export interface ReadNormOptions {
|
|
|
66
67
|
maxLines?: number;
|
|
67
68
|
store?: HashStore;
|
|
68
69
|
noPersist?: boolean;
|
|
70
|
+
allocation?: "real" | "shadow";
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export async function readNormFile(
|
|
@@ -94,7 +96,7 @@ export async function readNormFile(
|
|
|
94
96
|
|
|
95
97
|
if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
|
|
96
98
|
|
|
97
|
-
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
|
|
99
|
+
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true, options?.allocation === "shadow");
|
|
98
100
|
let identity = file.identity;
|
|
99
101
|
if (!identity) {
|
|
100
102
|
const { dev, ino } = await stat(resolvedPath);
|
|
@@ -126,6 +128,7 @@ export async function tryReadNormFile(
|
|
|
126
128
|
if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return undefined;
|
|
127
129
|
if (error instanceof Error) {
|
|
128
130
|
const msg = error.message;
|
|
131
|
+
if (msg.startsWith(ANCHOR_POOL_EXHAUSTED_PREFIX)) throw error;
|
|
129
132
|
if (msg.startsWith("[E_FILE_TOO_LARGE]") || msg.startsWith("[E_NOT_FOUND]") || msg.startsWith("[E_ACCESS]") || msg.startsWith("[E_NOT_TEXT]")) return undefined;
|
|
130
133
|
}
|
|
131
134
|
throw error;
|
package/src/grep.ts
CHANGED
|
@@ -7,19 +7,23 @@ import { spawn, spawnSync } from "child_process";
|
|
|
7
7
|
import { createInterface } from "readline";
|
|
8
8
|
import { tryReadNormFile } from "./file-reader";
|
|
9
9
|
import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
|
|
10
|
-
import { MAX_GREP_LINE_BYTES } from "./constants";
|
|
10
|
+
import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
|
|
11
11
|
import { toCwd } from "./paths";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { normReq } from "./payload-contract";
|
|
14
|
-
import { recordServedSafe, buildServedMap } from "./served";
|
|
15
14
|
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
16
|
-
|
|
15
|
+
import { markServed as markServedScoped } from "./anchor-registry";
|
|
16
|
+
import { buildServedMap } from "./served";
|
|
17
17
|
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
18
18
|
|
|
19
19
|
function cmp(a: string, b: string): number {
|
|
20
20
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
function isPoolExhaustedError(error: unknown): boolean {
|
|
24
|
+
return error instanceof Error && error.message.startsWith(ANCHOR_POOL_EXHAUSTED_PREFIX);
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
export interface GrepReq {
|
|
24
28
|
pattern: string;
|
|
25
29
|
path?: string;
|
|
@@ -491,6 +495,16 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
491
495
|
let truncatedBy: "lines" | "bytes" | null = null;
|
|
492
496
|
let linesReplaced = 0;
|
|
493
497
|
let countOnly = false;
|
|
498
|
+
let poolSkipped = 0;
|
|
499
|
+
const readGrepFile = async (absPath: string) => {
|
|
500
|
+
try {
|
|
501
|
+
return await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, allocation: "real", signal });
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (!isPoolExhaustedError(error)) throw error;
|
|
504
|
+
poolSkipped += 1;
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
494
508
|
const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal);
|
|
495
509
|
const sortedFiles = [...rgMatches.keys()].sort(cmp);
|
|
496
510
|
for (let f = 0; f < sortedFiles.length; f++) {
|
|
@@ -506,7 +520,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
506
520
|
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
507
521
|
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
|
|
508
522
|
}
|
|
509
|
-
const norm = await
|
|
523
|
+
const norm = await readGrepFile(absPath);
|
|
510
524
|
if (!norm) continue;
|
|
511
525
|
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
|
|
512
526
|
const display = displayRowsForHit(hit);
|
|
@@ -532,7 +546,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
532
546
|
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
533
547
|
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
|
|
534
548
|
}
|
|
535
|
-
const norm = await
|
|
549
|
+
const norm = await readGrepFile(absPath);
|
|
536
550
|
if (!norm) continue;
|
|
537
551
|
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
|
|
538
552
|
if (!hit) continue;
|
|
@@ -571,8 +585,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
571
585
|
}
|
|
572
586
|
hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
|
|
573
587
|
for (const hit of hits) {
|
|
574
|
-
|
|
575
|
-
await recordServedSafe(hit.path, servedMap, "anchor_grep", new Set(hit.fileHashes));
|
|
588
|
+
markServedScoped(hit.path, buildServedMap(hit.fileHashes, hit.fileLines, hit.hashes), new Set(hit.fileHashes));
|
|
576
589
|
}
|
|
577
590
|
const blocks = hits
|
|
578
591
|
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
|
@@ -581,6 +594,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
581
594
|
if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
|
|
582
595
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
583
596
|
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.]`);
|
|
597
|
+
if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; free anchors with /clear-anchors or narrow the search.]`);
|
|
584
598
|
const truncated = limitTruncated || rowTruncated;
|
|
585
599
|
const truncation: TruncationResult | undefined = rowTruncated
|
|
586
600
|
? {
|
|
@@ -597,7 +611,11 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
597
611
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
598
612
|
}
|
|
599
613
|
: undefined;
|
|
600
|
-
const text = blocks.length > 0
|
|
614
|
+
const text = blocks.length > 0
|
|
615
|
+
? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}`
|
|
616
|
+
: notes.length > 0
|
|
617
|
+
? `No matches found.\n${notes.join("\n")}`
|
|
618
|
+
: "No matches found.";
|
|
601
619
|
return {
|
|
602
620
|
content: [{ type: "text", text }],
|
|
603
621
|
details: {
|
package/src/hash-store/cache.ts
CHANGED
|
@@ -26,10 +26,6 @@ export function touchSession(path: string): void {
|
|
|
26
26
|
sessionTouched.set(path, sessionSeq);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function sessionRank(path: string): number | undefined {
|
|
30
|
-
return sessionTouched.get(path);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
29
|
export function clearSession(): void {
|
|
34
30
|
sessionTouched.clear();
|
|
35
31
|
sessionSeq = 0;
|
package/src/hash-store.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
retriedWrite,
|
|
21
21
|
openDbWithBusyRetryAsync,
|
|
22
22
|
} from "./hash-store/retry";
|
|
23
|
-
import { snapshotCache, cacheSnapshot, SNAPSHOT_CACHE_LIMIT, touchSession,
|
|
23
|
+
import { snapshotCache, cacheSnapshot, SNAPSHOT_CACHE_LIMIT, touchSession, clearSession, forgetSession } from "./hash-store/cache";
|
|
24
24
|
|
|
25
25
|
export { isValidHashList, isValidServedMap, parseHashList, parseServedMap, parseStoredHashes, parseStoredServed, isCorruptionError };
|
|
26
26
|
export { SNAPSHOT_CACHE_LIMIT };
|
|
@@ -84,20 +84,16 @@ if (typeof process !== "undefined" && (process.versions as Record<string, string
|
|
|
84
84
|
|
|
85
85
|
interface Prepared {
|
|
86
86
|
get: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
87
|
+
getState: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
87
88
|
allPaths: (...params: SqlParams) => Record<string, unknown>[];
|
|
88
89
|
allHashes: (...params: SqlParams) => Record<string, unknown>[];
|
|
89
|
-
allServed: (...params: SqlParams) => Record<string, unknown>[];
|
|
90
90
|
deleteOne: (...params: SqlParams) => void;
|
|
91
91
|
upsert: (...params: SqlParams) => void;
|
|
92
92
|
undoUpsert: (...params: SqlParams) => void;
|
|
93
93
|
undoGet: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
94
94
|
undoDelete: (...params: SqlParams) => void;
|
|
95
|
-
servedGet: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
96
|
-
servedUpsert: (...params: SqlParams) => void;
|
|
97
|
-
servedDelete: (...params: SqlParams) => void;
|
|
98
95
|
snapshotTime: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
99
96
|
undoTime: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
100
|
-
servedTime: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
101
97
|
}
|
|
102
98
|
|
|
103
99
|
export interface HashStore {
|
|
@@ -158,31 +154,26 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
158
154
|
"updated_at INTEGER NOT NULL" +
|
|
159
155
|
")"
|
|
160
156
|
);
|
|
161
|
-
|
|
162
|
-
"
|
|
163
|
-
|
|
164
|
-
"hashes TEXT NOT NULL, " +
|
|
165
|
-
"updated_at INTEGER NOT NULL" +
|
|
166
|
-
")"
|
|
167
|
-
);
|
|
157
|
+
try {
|
|
158
|
+
db.exec("ALTER TABLE snapshots ADD COLUMN line_checksums TEXT");
|
|
159
|
+
} catch {}
|
|
168
160
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
169
161
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
170
162
|
db.exec("DELETE FROM snapshots");
|
|
171
163
|
db.exec("DELETE FROM undo");
|
|
172
|
-
db.exec("DELETE FROM served");
|
|
173
164
|
}
|
|
174
165
|
db.prepare(
|
|
175
166
|
"INSERT INTO meta (key, value) VALUES ('version', ?) " +
|
|
176
167
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
177
168
|
).run(String(HASH_STORE_VERSION));
|
|
178
169
|
const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
|
|
179
|
-
const
|
|
170
|
+
const getStateStmt = db.prepare("SELECT checksum, hashes, line_checksums FROM snapshots WHERE path = ?");
|
|
171
|
+
const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
|
|
180
172
|
const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
|
|
181
|
-
const allServedStmt = db.prepare("SELECT path, hashes FROM served");
|
|
182
173
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
183
174
|
const upsertStmt = db.prepare(
|
|
184
|
-
"INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
|
|
185
|
-
"ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at"
|
|
175
|
+
"INSERT INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?) " +
|
|
176
|
+
"ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, line_checksums = excluded.line_checksums, updated_at = excluded.updated_at"
|
|
186
177
|
);
|
|
187
178
|
const undoUpsertStmt = db.prepare(
|
|
188
179
|
"INSERT INTO undo (path, content, bom, ending, hashes, result_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " +
|
|
@@ -192,31 +183,20 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
192
183
|
"SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
|
|
193
184
|
);
|
|
194
185
|
const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
|
|
195
|
-
const servedGetStmt = db.prepare("SELECT hashes FROM served WHERE path = ?");
|
|
196
|
-
const servedUpsertStmt = db.prepare(
|
|
197
|
-
"INSERT INTO served (path, hashes, updated_at) VALUES (?, ?, ?) " +
|
|
198
|
-
"ON CONFLICT(path) DO UPDATE SET hashes = excluded.hashes, updated_at = excluded.updated_at"
|
|
199
|
-
);
|
|
200
|
-
const servedDelStmt = db.prepare("DELETE FROM served WHERE path = ?");
|
|
201
186
|
const snapshotTimeStmt = db.prepare("SELECT updated_at FROM snapshots WHERE path = ?");
|
|
202
187
|
const undoTimeStmt = db.prepare("SELECT updated_at FROM undo WHERE path = ?");
|
|
203
|
-
const servedTimeStmt = db.prepare("SELECT updated_at FROM served WHERE path = ?");
|
|
204
188
|
const stmts: Prepared = {
|
|
205
189
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
190
|
+
getState: (...params) => getStateStmt.get(...params) as Record<string, unknown> | undefined,
|
|
206
191
|
allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
|
|
207
192
|
allHashes: (...params) => allHashesStmt.all(...params) as Record<string, unknown>[],
|
|
208
|
-
allServed: (...params) => allServedStmt.all(...params) as Record<string, unknown>[],
|
|
209
193
|
deleteOne: retriedWrite(delStmt),
|
|
210
194
|
upsert: retriedWrite(upsertStmt),
|
|
211
195
|
undoUpsert: retriedWrite(undoUpsertStmt),
|
|
212
196
|
undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
213
197
|
undoDelete: retriedWrite(undoDelStmt),
|
|
214
|
-
servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
215
|
-
servedUpsert: retriedWrite(servedUpsertStmt),
|
|
216
|
-
servedDelete: retriedWrite(servedDelStmt),
|
|
217
198
|
snapshotTime: (...params) => snapshotTimeStmt.get(...params) as Record<string, unknown> | undefined,
|
|
218
199
|
undoTime: (...params) => undoTimeStmt.get(...params) as Record<string, unknown> | undefined,
|
|
219
|
-
servedTime: (...params) => servedTimeStmt.get(...params) as Record<string, unknown> | undefined,
|
|
220
200
|
};
|
|
221
201
|
return { db, stmts };
|
|
222
202
|
}
|
|
@@ -391,7 +371,7 @@ async function migrateLegacy(db: RawDb): Promise<void> {
|
|
|
391
371
|
const raw = parsed.snapshots;
|
|
392
372
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
393
373
|
|
|
394
|
-
const rows: [string, string, number, string, number][] = [];
|
|
374
|
+
const rows: [string, string, number, string, string, number][] = [];
|
|
395
375
|
for (const [key, value] of Object.entries(raw)) {
|
|
396
376
|
if (
|
|
397
377
|
isRec(value) &&
|
|
@@ -409,6 +389,7 @@ async function migrateLegacy(db: RawDb): Promise<void> {
|
|
|
409
389
|
contentChecksum(value.content),
|
|
410
390
|
splitLines(value.content).length,
|
|
411
391
|
JSON.stringify(value.hashes),
|
|
392
|
+
"",
|
|
412
393
|
Date.now(),
|
|
413
394
|
]);
|
|
414
395
|
}
|
|
@@ -417,7 +398,7 @@ async function migrateLegacy(db: RawDb): Promise<void> {
|
|
|
417
398
|
db.exec("BEGIN IMMEDIATE");
|
|
418
399
|
try {
|
|
419
400
|
const stmt = db.prepare(
|
|
420
|
-
"INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
|
|
401
|
+
"INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
421
402
|
);
|
|
422
403
|
for (const row of rows) stmt.run(...row);
|
|
423
404
|
db.exec("COMMIT");
|
|
@@ -465,8 +446,9 @@ export function upsertSnapshot(
|
|
|
465
446
|
checksum: string,
|
|
466
447
|
lineCount: number,
|
|
467
448
|
hashes: string[],
|
|
449
|
+
lineChecksums?: string[],
|
|
468
450
|
): void {
|
|
469
|
-
store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
|
|
451
|
+
store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), lineChecksums ? JSON.stringify(lineChecksums) : "", Date.now());
|
|
470
452
|
cacheSnapshot(path, checksum, lineCount, hashes);
|
|
471
453
|
touchSession(path);
|
|
472
454
|
}
|
|
@@ -475,8 +457,39 @@ export function persistSnapshot(
|
|
|
475
457
|
path: string,
|
|
476
458
|
content: string,
|
|
477
459
|
hashes: string[],
|
|
460
|
+
lineChecksums?: string[],
|
|
478
461
|
): void {
|
|
479
|
-
upsertSnapshot(store, path, contentChecksum(content), splitLines(content).length, hashes);
|
|
462
|
+
upsertSnapshot(store, path, contentChecksum(content), splitLines(content).length, hashes, lineChecksums);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export interface AllocatedState {
|
|
466
|
+
anchors: string[];
|
|
467
|
+
checksums: string[] | undefined;
|
|
468
|
+
contentChecksum: string;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function getAllocatedState(store: HashStore, path: string, deleteCorrupt = true): AllocatedState | undefined {
|
|
472
|
+
const row = withBusyRetry(() => store.stmts.getState(path)) as
|
|
473
|
+
| { checksum?: unknown; hashes?: unknown; line_checksums?: unknown }
|
|
474
|
+
| undefined;
|
|
475
|
+
if (!row || typeof row.checksum !== "string") return undefined;
|
|
476
|
+
const parsedAnchors = parseStoredHashes({ hashes: row.hashes }, () => {
|
|
477
|
+
if (deleteCorrupt) store.stmts.deleteOne(path);
|
|
478
|
+
snapshotCache.delete(path);
|
|
479
|
+
});
|
|
480
|
+
if (!parsedAnchors) return undefined;
|
|
481
|
+
let checksums: string[] | undefined;
|
|
482
|
+
if (typeof row.line_checksums === "string" && row.line_checksums.length > 0) {
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(row.line_checksums) as unknown;
|
|
485
|
+
if (Array.isArray(parsed) && parsed.every((c) => typeof c === "string")) {
|
|
486
|
+
checksums = parsed as string[];
|
|
487
|
+
}
|
|
488
|
+
} catch {
|
|
489
|
+
checksums = undefined;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return { anchors: parsedAnchors, checksums, contentChecksum: row.checksum };
|
|
480
493
|
}
|
|
481
494
|
|
|
482
495
|
export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
|
|
@@ -537,119 +550,19 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
|
|
|
537
550
|
return missing;
|
|
538
551
|
}
|
|
539
552
|
|
|
540
|
-
export async function pruneMissing(store: HashStore): Promise<
|
|
553
|
+
export async function pruneMissing(store: HashStore): Promise<string[]> {
|
|
541
554
|
const rows = store.stmts.allPaths() as { path: string }[];
|
|
542
555
|
const missing = await statMissing(rows);
|
|
543
|
-
if (missing.length === 0) return;
|
|
556
|
+
if (missing.length === 0) return [];
|
|
544
557
|
withStore(() => {
|
|
545
558
|
for (const path of missing) {
|
|
546
559
|
store.stmts.deleteOne(path);
|
|
547
|
-
store.stmts.servedDelete(path);
|
|
548
560
|
}
|
|
549
561
|
});
|
|
550
562
|
for (const path of missing) snapshotCache.delete(path);
|
|
551
563
|
for (const path of missing) forgetSession(path);
|
|
564
|
+
return missing;
|
|
552
565
|
}
|
|
553
566
|
|
|
554
|
-
function matchPathsByHashes(
|
|
555
|
-
rows: { path: string; hashes: string }[],
|
|
556
|
-
hashes: string[],
|
|
557
|
-
): string[] {
|
|
558
|
-
const needed = new Set(hashes);
|
|
559
|
-
if (needed.size === 0) return [];
|
|
560
|
-
const matches: string[] = [];
|
|
561
|
-
for (const row of rows) {
|
|
562
|
-
try {
|
|
563
|
-
const parsed = JSON.parse(row.hashes) as unknown;
|
|
564
|
-
if (!isValidHashList(parsed)) continue;
|
|
565
|
-
const parsedSet = new Set(parsed);
|
|
566
|
-
let ok = true;
|
|
567
|
-
for (const h of needed) {
|
|
568
|
-
if (!parsedSet.has(h)) {
|
|
569
|
-
ok = false;
|
|
570
|
-
break;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
if (ok) matches.push(row.path);
|
|
574
|
-
} catch {
|
|
575
|
-
continue;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
return matches;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
function matchPathsByServed(
|
|
582
|
-
rows: { path: string; hashes: string }[],
|
|
583
|
-
hashes: string[],
|
|
584
|
-
): string[] {
|
|
585
|
-
const needed = new Set(hashes);
|
|
586
|
-
if (needed.size === 0) return [];
|
|
587
|
-
const matches: string[] = [];
|
|
588
|
-
for (const row of rows) {
|
|
589
|
-
try {
|
|
590
|
-
const parsed = JSON.parse(row.hashes) as unknown;
|
|
591
|
-
if (!isValidServedMap(parsed)) continue;
|
|
592
|
-
const keySet = new Set(Object.keys(parsed as Record<string, unknown>));
|
|
593
|
-
let ok = true;
|
|
594
|
-
for (const h of needed) {
|
|
595
|
-
if (!keySet.has(h)) {
|
|
596
|
-
ok = false;
|
|
597
|
-
break;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
if (ok) matches.push(row.path);
|
|
601
|
-
} catch {
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
return matches;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
|
|
609
|
-
return matchPathsByHashes(store.stmts.allHashes() as { path: string; hashes: string }[], hashes);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
export function findServedPaths(store: HashStore, hashes: string[]): string[] {
|
|
613
|
-
return matchPathsByServed(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
export function pathActivity(store: HashStore, path: string): number {
|
|
617
|
-
let activity = 0;
|
|
618
|
-
try {
|
|
619
|
-
const snap = withBusyRetry(() => store.stmts.snapshotTime(path)) as { updated_at?: unknown } | undefined;
|
|
620
|
-
const snapTime = typeof snap?.updated_at === "number" ? snap.updated_at : 0;
|
|
621
|
-
if (snapTime > activity) activity = snapTime;
|
|
622
|
-
} catch {
|
|
623
|
-
}
|
|
624
|
-
try {
|
|
625
|
-
const undo = withBusyRetry(() => store.stmts.undoTime(path)) as { updated_at?: unknown } | undefined;
|
|
626
|
-
const undoTime = typeof undo?.updated_at === "number" ? undo.updated_at : 0;
|
|
627
|
-
if (undoTime > activity) activity = undoTime;
|
|
628
|
-
} catch {
|
|
629
|
-
}
|
|
630
|
-
try {
|
|
631
|
-
const served = withBusyRetry(() => store.stmts.servedTime(path)) as { updated_at?: unknown } | undefined;
|
|
632
|
-
const servedTime = typeof served?.updated_at === "number" ? served.updated_at : 0;
|
|
633
|
-
if (servedTime > activity) activity = servedTime;
|
|
634
|
-
} catch {
|
|
635
|
-
}
|
|
636
|
-
return activity;
|
|
637
|
-
}
|
|
638
567
|
|
|
639
|
-
export function rankRecentPaths(store: HashStore, candidates: string[]): string[] {
|
|
640
|
-
const scored = candidates.map((candidate) => {
|
|
641
|
-
const rank = sessionRank(candidate) ?? -1;
|
|
642
|
-
const activity = rank < 0 ? pathActivity(store, candidate) : -1;
|
|
643
|
-
return { candidate, rank, activity };
|
|
644
|
-
});
|
|
645
|
-
scored.sort((a, b) => {
|
|
646
|
-
if (a.rank !== b.rank) return b.rank - a.rank;
|
|
647
|
-
if (a.activity !== b.activity) return b.activity - a.activity;
|
|
648
|
-
return a.candidate < b.candidate ? -1 : a.candidate > b.candidate ? 1 : 0;
|
|
649
|
-
});
|
|
650
|
-
return scored.map((entry) => entry.candidate);
|
|
651
|
-
}
|
|
652
568
|
|
|
653
|
-
export function pickRecentPath(store: HashStore, candidates: string[]): string {
|
|
654
|
-
return rankRecentPaths(store, candidates)[0]!;
|
|
655
|
-
}
|