pi-hashline-edit-pro 3.0.3 → 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.
@@ -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 editRenderResultWrapper(
10
- result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
11
- opts: { isPartial: boolean; expanded?: boolean } | boolean,
12
- theme: FgT,
13
- context: any,
14
- ) {
15
- return renderEditResult(result, opts, theme, context);
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, { getInput, toolName });
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 = {
@@ -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 tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
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 tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
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
- const servedMap = buildServedMap(hit.fileHashes, hit.fileLines, hit.hashes);
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 ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}` : "No matches found.";
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: {
@@ -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, sessionRank, clearSession, forgetSession } from "./hash-store/cache";
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
- db.exec(
162
- "CREATE TABLE IF NOT EXISTS served (" +
163
- "path TEXT PRIMARY KEY, " +
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 allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM served");
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,120 +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<void> {
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
567
 
612
- export function findServedPaths(store: HashStore, hashes: string[]): string[] {
613
- return matchPathsByServed(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
614
- }
615
568
 
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
-
639
- export function pickRecentPath(store: HashStore, candidates: string[]): string {
640
- let best = candidates[0]!;
641
- let bestSession = -1;
642
- let bestActivity = -1;
643
- for (const candidate of candidates) {
644
- const rank = sessionRank(candidate) ?? -1;
645
- let activity = -1;
646
- if (rank < 0) {
647
- activity = pathActivity(store, candidate);
648
- }
649
- if (rank > bestSession || (rank === bestSession && activity > bestActivity)) {
650
- best = candidate;
651
- bestSession = rank;
652
- bestActivity = activity;
653
- }
654
- }
655
- return best;
656
- }