@drewpayment/mink 0.11.0 → 0.12.0-beta.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.
Files changed (73) hide show
  1. package/dashboard/out/404.html +1 -1
  2. package/dashboard/out/action-log.html +1 -1
  3. package/dashboard/out/action-log.txt +1 -1
  4. package/dashboard/out/activity.html +1 -1
  5. package/dashboard/out/activity.txt +1 -1
  6. package/dashboard/out/bugs.html +1 -1
  7. package/dashboard/out/bugs.txt +1 -1
  8. package/dashboard/out/capture.html +1 -1
  9. package/dashboard/out/capture.txt +1 -1
  10. package/dashboard/out/config.html +1 -1
  11. package/dashboard/out/config.txt +1 -1
  12. package/dashboard/out/daemon.html +1 -1
  13. package/dashboard/out/daemon.txt +1 -1
  14. package/dashboard/out/design.html +1 -1
  15. package/dashboard/out/design.txt +1 -1
  16. package/dashboard/out/discord.html +1 -1
  17. package/dashboard/out/discord.txt +1 -1
  18. package/dashboard/out/file-index.html +1 -1
  19. package/dashboard/out/file-index.txt +1 -1
  20. package/dashboard/out/index.html +1 -1
  21. package/dashboard/out/index.txt +1 -1
  22. package/dashboard/out/insights.html +1 -1
  23. package/dashboard/out/insights.txt +1 -1
  24. package/dashboard/out/learning.html +1 -1
  25. package/dashboard/out/learning.txt +1 -1
  26. package/dashboard/out/overview.html +1 -1
  27. package/dashboard/out/overview.txt +1 -1
  28. package/dashboard/out/scheduler.html +1 -1
  29. package/dashboard/out/scheduler.txt +1 -1
  30. package/dashboard/out/sync.html +1 -1
  31. package/dashboard/out/sync.txt +1 -1
  32. package/dashboard/out/tokens.html +1 -1
  33. package/dashboard/out/tokens.txt +1 -1
  34. package/dashboard/out/waste.html +1 -1
  35. package/dashboard/out/waste.txt +1 -1
  36. package/dashboard/out/wiki.html +1 -1
  37. package/dashboard/out/wiki.txt +1 -1
  38. package/dist/cli.bun.js +90615 -0
  39. package/dist/cli.js +27 -92464
  40. package/dist/cli.node.js +93945 -0
  41. package/package.json +13 -2
  42. package/scripts/build.mjs +50 -0
  43. package/scripts/cli-shim.mjs +39 -0
  44. package/src/commands/bug-search.ts +2 -4
  45. package/src/commands/detect-waste.ts +24 -32
  46. package/src/commands/post-read.ts +10 -11
  47. package/src/commands/post-write.ts +13 -19
  48. package/src/commands/pre-read.ts +19 -24
  49. package/src/commands/scan.ts +103 -40
  50. package/src/commands/status.ts +45 -26
  51. package/src/core/bug-memory.ts +32 -34
  52. package/src/core/dashboard-api.ts +44 -22
  53. package/src/core/index-store.ts +23 -0
  54. package/src/core/paths.ts +7 -0
  55. package/src/core/scanner.ts +8 -4
  56. package/src/core/state-aggregator.ts +64 -7
  57. package/src/core/state-counters.ts +11 -31
  58. package/src/core/sync-merge-drivers.ts +164 -1
  59. package/src/core/sync.ts +9 -0
  60. package/src/core/token-ledger.ts +50 -4
  61. package/src/repositories/bug-memory-repo.ts +268 -0
  62. package/src/repositories/counters-repo.ts +88 -0
  63. package/src/repositories/file-index-repo.ts +238 -0
  64. package/src/repositories/token-ledger-repo.ts +412 -0
  65. package/src/storage/db.ts +121 -0
  66. package/src/storage/driver.bun.ts +99 -0
  67. package/src/storage/driver.node.ts +107 -0
  68. package/src/storage/driver.ts +76 -0
  69. package/src/storage/migrate-json.ts +415 -0
  70. package/src/storage/schema.ts +207 -0
  71. package/src/types/file-index.ts +9 -0
  72. /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 7bx94K8a7-O53mwi7UoEu}/_buildManifest.js +0 -0
  73. /package/dashboard/out/_next/static/{I7QxkFr_LXY4BWGjogOs1 → 7bx94K8a7-O53mwi7UoEu}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@drewpayment/mink",
3
- "version": "0.11.0",
3
+ "version": "0.12.0-beta.2",
4
4
  "description": "A hidden presence that moves alongside the developer — token efficiency and cross-project wiki for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mink": "./dist/cli.js"
8
8
  },
9
+ "engines": {
10
+ "node": ">=22.5.0"
11
+ },
9
12
  "scripts": {
10
- "build": "bun build src/cli.ts --outfile dist/cli.js --target node --format esm && node -e \"const f=require('fs');let c=f.readFileSync('dist/cli.js','utf8');c=c.replace(/^#!.*\\n/,'');f.writeFileSync('dist/cli.js','#!/usr/bin/env node\\n'+c);f.chmodSync('dist/cli.js',0o755)\"",
13
+ "build": "node scripts/build.mjs",
11
14
  "postinstall": "bun run build 2>/dev/null || true",
12
15
  "typecheck": "bunx tsc --noEmit",
13
16
  "test": "bun test",
@@ -18,6 +21,10 @@
18
21
  "files": [
19
22
  "src/**/*.ts",
20
23
  "dist/cli.js",
24
+ "dist/cli.node.js",
25
+ "dist/cli.bun.js",
26
+ "scripts/build.mjs",
27
+ "scripts/cli-shim.mjs",
21
28
  "skills/**/*",
22
29
  "agents/**/*",
23
30
  "dashboard/out"
@@ -25,6 +32,10 @@
25
32
  "publishConfig": {
26
33
  "access": "public"
27
34
  },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/drewpayment/mink.git"
38
+ },
28
39
  "license": "MIT",
29
40
  "dependencies": {
30
41
  "puppeteer-core": "^24.0.0"
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // Build both runtime bundles, plus a Node shim that dispatches to whichever
3
+ // runtime is available at exec time. Each `bun build` invocation feeds a
4
+ // `--define MINK_RUNTIME=...` value that the storage driver dispatcher in
5
+ // `src/storage/driver.ts` constant-folds, so the unused branch's
6
+ // `require("bun:sqlite")` / `require("node:sqlite")` is never executed at
7
+ // runtime — even though both strings are present in the bundle source.
8
+ //
9
+ // Outputs:
10
+ // dist/cli.bun.js — bun bundle (faster startup when Bun is installed)
11
+ // dist/cli.node.js — node bundle (works wherever Node ≥22.5 is)
12
+ // dist/cli.js — Node shim, the only entry exposed via `bin`
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import { chmodSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
+ import { dirname } from "node:path";
17
+
18
+ const SRC = "src/cli.ts";
19
+
20
+ const TARGETS = [
21
+ { runtime: "bun", outfile: "dist/cli.bun.js", target: "bun", shebang: "#!/usr/bin/env bun" },
22
+ { runtime: "node", outfile: "dist/cli.node.js", target: "node", shebang: "#!/usr/bin/env node" },
23
+ ];
24
+
25
+ function run(cmd, args) {
26
+ execFileSync(cmd, args, { stdio: "inherit" });
27
+ }
28
+
29
+ for (const t of TARGETS) {
30
+ mkdirSync(dirname(t.outfile), { recursive: true });
31
+ run("bun", [
32
+ "build", SRC,
33
+ "--outfile", t.outfile,
34
+ "--target", t.target,
35
+ "--format", "esm",
36
+ "--define", `MINK_RUNTIME="${t.runtime}"`,
37
+ ]);
38
+
39
+ // `bun build` may emit its own shebang depending on the target. Strip any
40
+ // existing line beginning with `#!` and prepend the canonical one.
41
+ let body = readFileSync(t.outfile, "utf-8");
42
+ body = body.replace(/^#!.*\n/, "");
43
+ writeFileSync(t.outfile, `${t.shebang}\n${body}`);
44
+ chmodSync(t.outfile, 0o755);
45
+ console.log(`built ${t.outfile} (${t.runtime})`);
46
+ }
47
+
48
+ copyFileSync("scripts/cli-shim.mjs", "dist/cli.js");
49
+ chmodSync("dist/cli.js", 0o755);
50
+ console.log("built dist/cli.js (shim)");
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // Dispatch shim copied verbatim to dist/cli.js by scripts/build.mjs.
3
+ // Prefers Bun for the faster runtime path; falls back to the Node bundle
4
+ // in-process so the cold-start tax is zero on Node-only machines.
5
+ // Set MINK_RUNTIME=node to force the Node bundle even when Bun is present.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ const bunBundle = join(here, "cli.bun.js");
14
+ const nodeBundle = join(here, "cli.node.js");
15
+
16
+ if (typeof globalThis.Bun !== "undefined") {
17
+ await import(bunBundle);
18
+ } else {
19
+ const bunPath = process.env.MINK_RUNTIME === "node" ? null : findOnPath("bun");
20
+ if (bunPath) {
21
+ const r = spawnSync(bunPath, [bunBundle, ...process.argv.slice(2)], { stdio: "inherit" });
22
+ process.exit(r.status ?? 1);
23
+ } else {
24
+ await import(nodeBundle);
25
+ }
26
+ }
27
+
28
+ function findOnPath(name) {
29
+ const sep = process.platform === "win32" ? ";" : ":";
30
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
31
+ for (const dir of (process.env.PATH ?? "").split(sep)) {
32
+ if (!dir) continue;
33
+ for (const ext of exts) {
34
+ const p = join(dir, name + ext);
35
+ if (existsSync(p)) return p;
36
+ }
37
+ }
38
+ return null;
39
+ }
@@ -1,5 +1,4 @@
1
- import { searchBugs } from "../core/bug-memory";
2
- import { aggregateBugMemory } from "../core/state-aggregator";
1
+ import { BugMemoryRepo } from "../repositories/bug-memory-repo";
3
2
 
4
3
  export function bugSearch(cwd: string, query: string): void {
5
4
  if (!query) {
@@ -7,8 +6,7 @@ export function bugSearch(cwd: string, query: string): void {
7
6
  process.exit(1);
8
7
  }
9
8
 
10
- const memory = aggregateBugMemory(cwd);
11
- const results = searchBugs(memory, query);
9
+ const results = BugMemoryRepo.for(cwd).searchBugs(query);
12
10
 
13
11
  if (results.length === 0) {
14
12
  console.log("No matching bugs found.");
@@ -1,24 +1,18 @@
1
1
  import { statSync } from "fs";
2
- import {
3
- tokenLedgerShardPath,
4
- fileIndexPath,
5
- learningMemoryPath,
6
- } from "../core/paths";
7
- import { loadLedger, saveLedger } from "../core/token-ledger";
8
- import { isFileIndex, createEmptyIndex } from "../core/index-store";
2
+ import { learningMemoryPath } from "../core/paths";
3
+ import { FileIndexRepo } from "../repositories/file-index-repo";
4
+ import { TokenLedgerRepo } from "../repositories/token-ledger-repo";
9
5
  import {
10
6
  aggregateTokenLedger,
11
7
  aggregateActionLog,
12
8
  } from "../core/state-aggregator";
13
9
  import { loadCounters } from "../core/state-counters";
14
- import { safeReadJson } from "../core/fs-utils";
15
10
  import { runDetection } from "../core/waste-detection";
16
11
  import { getOrCreateDeviceId } from "../core/device";
17
12
  import type { TokenLedger } from "../types/token-ledger";
18
- import type { FileIndex } from "../types/file-index";
13
+ import type { FileIndexEntry } from "../types/file-index";
19
14
 
20
15
  export function detectWaste(cwd: string): void {
21
- const idxPath = fileIndexPath(cwd);
22
16
  const lmPath = learningMemoryPath(cwd);
23
17
 
24
18
  // Aggregated ledger (across all device shards + legacy). Aggregator returns
@@ -27,14 +21,14 @@ export function detectWaste(cwd: string): void {
27
21
  // already logged by loadLedger.
28
22
  const ledger: TokenLedger = aggregateTokenLedger(cwd);
29
23
 
30
- // Load file index
31
- const rawIndex = safeReadJson(idxPath);
32
- let fileIndex: FileIndex;
33
- if (rawIndex !== null && isFileIndex(rawIndex)) {
34
- fileIndex = rawIndex;
35
- } else {
36
- fileIndex = createEmptyIndex();
37
- }
24
+ // Load file index — read every entry into the map shape the waste
25
+ // detector expects. listAll() walks the table once; under 20k rows it
26
+ // returns in single-digit ms.
27
+ const repo = FileIndexRepo.for(cwd);
28
+ const entries: Record<string, FileIndexEntry> = {};
29
+ for (const e of repo.listAll()) entries[e.filePath] = e;
30
+ const totalFiles = repo.totalFiles();
31
+ const lastScanTimestamp = repo.getLastScanTimestamp();
38
32
 
39
33
  // Aggregated action log content (across all device shards + legacy)
40
34
  const actionLogContent = aggregateActionLog(cwd);
@@ -47,32 +41,30 @@ export function detectWaste(cwd: string): void {
47
41
  // File missing — will be flagged as stale
48
42
  }
49
43
 
50
- // Pull hit/miss telemetry from the per-device counter file, falling back to
51
- // the legacy header counters when unmigrated. We feed runDetection a synthetic
52
- // header so it works without knowing about the split.
44
+ // Pull hit/miss telemetry from the SQLite counters table. We synthesize
45
+ // the header shape the detector expects (lifetimeHits/Misses lived in
46
+ // the JSON header pre-migration).
53
47
  const counters = loadCounters(cwd);
54
48
  const headerForDetection = {
55
- ...fileIndex.header,
56
- lifetimeHits: counters.fileIndexHits || fileIndex.header.lifetimeHits,
57
- lifetimeMisses: counters.fileIndexMisses || fileIndex.header.lifetimeMisses,
49
+ lastScanTimestamp,
50
+ totalFiles,
51
+ lifetimeHits: counters.fileIndexHits,
52
+ lifetimeMisses: counters.fileIndexMisses,
58
53
  };
59
54
 
60
55
  // Run detection on the aggregated cross-device view
61
56
  const flags = runDetection(
62
57
  ledger,
63
- fileIndex.entries,
58
+ entries,
64
59
  headerForDetection,
65
60
  actionLogContent,
66
61
  learningMemoryMtimeMs
67
62
  );
68
63
 
69
- // Persist flags in THIS device's shard ledger so it's the only writer for
70
- // that file. The aggregator unions wasteFlags across shards on read, so
71
- // every device's view stays current without merge conflicts.
72
- const shardLedgerPath = tokenLedgerShardPath(cwd, getOrCreateDeviceId());
73
- const shardLedger = loadLedger(shardLedgerPath);
74
- shardLedger.wasteFlags = flags;
75
- saveLedger(shardLedgerPath, shardLedger);
64
+ // Persist flags in THIS device's waste_flags rows. The merge driver
65
+ // unions across devices on sync; replaceWasteFlagsForDevice clears
66
+ // and rewrites this device's set on every detection run.
67
+ TokenLedgerRepo.for(cwd).replaceWasteFlagsForDevice(getOrCreateDeviceId(), flags);
76
68
 
77
69
  // Output summary
78
70
  if (flags.length === 0) {
@@ -1,14 +1,14 @@
1
1
  import { relative } from "path";
2
2
  import { readStdinJson } from "../core/stdin";
3
- import { sessionPath, fileIndexPath, actionLogShardPath } from "../core/paths";
3
+ import { sessionPath, actionLogShardPath } from "../core/paths";
4
4
  import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
5
5
  import { createSessionState, isSessionState, recordRead } from "../core/session";
6
- import { isFileIndex, lookupEntry } from "../core/index-store";
6
+ import { FileIndexRepo } from "../repositories/file-index-repo";
7
7
  import { estimateTokens, isBinaryFile } from "../core/token-estimate";
8
8
  import { createActionLogWriter } from "../core/action-log";
9
9
  import { getOrCreateDeviceId } from "../core/device";
10
10
  import type { SessionState } from "../types/session";
11
- import type { FileIndex } from "../types/file-index";
11
+ import type { IndexLookup } from "../types/file-index";
12
12
  import type { PostToolUseInput } from "../types/hook-input";
13
13
 
14
14
  export interface PostReadResult {
@@ -20,17 +20,17 @@ export interface PostReadResult {
20
20
  export function analyzePostRead(
21
21
  filePath: string,
22
22
  content: string | null,
23
- index: FileIndex | null
23
+ index: IndexLookup | null
24
24
  ): PostReadResult {
25
25
  // Binary file — skip token estimation
26
26
  if (isBinaryFile(filePath, content ?? undefined)) {
27
- const entry = index ? lookupEntry(index, filePath) : null;
27
+ const entry = index ? index.lookupEntry(filePath) : null;
28
28
  return { estimatedTokens: 0, indexHit: !!entry, source: "none" };
29
29
  }
30
30
 
31
31
  // Content available — estimate from actual content
32
32
  if (content !== null && content.length > 0) {
33
- const entry = index ? lookupEntry(index, filePath) : null;
33
+ const entry = index ? index.lookupEntry(filePath) : null;
34
34
  return {
35
35
  estimatedTokens: estimateTokens(content, filePath),
36
36
  indexHit: !!entry,
@@ -40,7 +40,7 @@ export function analyzePostRead(
40
40
 
41
41
  // No content — try file index fallback
42
42
  if (index) {
43
- const entry = lookupEntry(index, filePath);
43
+ const entry = index.lookupEntry(filePath);
44
44
  if (entry) {
45
45
  return {
46
46
  estimatedTokens: entry.estimatedTokens,
@@ -89,14 +89,13 @@ export async function postRead(cwd: string): Promise<void> {
89
89
  ? rawState
90
90
  : createSessionState();
91
91
 
92
- // Load file index for token fallback and indexHit determination
93
- const rawIndex = safeReadJson(fileIndexPath(cwd));
94
- const index: FileIndex | null = isFileIndex(rawIndex) ? rawIndex : null;
92
+ // File index repository one-key lookup, no whole-index load.
93
+ const repo = FileIndexRepo.for(cwd);
95
94
 
96
95
  // Extract content from tool output
97
96
  const content = extractContent(input);
98
97
 
99
- const result = analyzePostRead(filePath, content, index);
98
+ const result = analyzePostRead(filePath, content, repo);
100
99
 
101
100
  // Record the read in session state
102
101
  recordRead(state, filePath, result.estimatedTokens, result.indexHit);
@@ -1,22 +1,17 @@
1
1
  import { relative } from "path";
2
2
  import { readFileSync } from "fs";
3
3
  import { readStdinJson } from "../core/stdin";
4
- import { sessionPath, fileIndexPath, actionLogShardPath } from "../core/paths";
4
+ import { sessionPath, actionLogShardPath } from "../core/paths";
5
5
  import { getOrCreateDeviceId } from "../core/device";
6
6
  import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
7
7
  import { createSessionState, isSessionState, recordWrite } from "../core/session";
8
- import {
9
- isFileIndex,
10
- lookupEntry,
11
- upsertEntry,
12
- createEmptyIndex,
13
- } from "../core/index-store";
8
+ import { FileIndexRepo } from "../repositories/file-index-repo";
14
9
  import { extractDescription } from "../core/description";
15
10
  import { estimateTokens, isBinaryFile } from "../core/token-estimate";
16
11
  import { isWriteExcluded } from "../core/write-exclusions";
17
12
  import { createActionLogWriter } from "../core/action-log";
18
13
  import type { SessionState } from "../types/session";
19
- import type { FileIndex, FileIndexEntry } from "../types/file-index";
14
+ import type { FileIndexEntry, IndexLookup } from "../types/file-index";
20
15
  import type { PostToolUseInput } from "../types/hook-input";
21
16
 
22
17
  export interface PostWriteResult {
@@ -30,7 +25,7 @@ export interface PostWriteResult {
30
25
  export function analyzePostWrite(
31
26
  filePath: string,
32
27
  fileContent: string | null,
33
- index: FileIndex | null
28
+ index: IndexLookup | null
34
29
  ): PostWriteResult {
35
30
  // Check exclusions
36
31
  if (isWriteExcluded(filePath)) {
@@ -43,8 +38,9 @@ export function analyzePostWrite(
43
38
  };
44
39
  }
45
40
 
46
- // Determine action from index presence
47
- const existingEntry = index ? lookupEntry(index, filePath) : null;
41
+ // Determine action from index presence (one-key lookup; never loads the
42
+ // whole index important for 20k-file projects).
43
+ const existingEntry = index ? index.lookupEntry(filePath) : null;
48
44
  const action: "create" | "edit" = existingEntry ? "edit" : "create";
49
45
 
50
46
  // Handle binary or unreadable content
@@ -117,17 +113,16 @@ export async function postWrite(cwd: string): Promise<void> {
117
113
  ? rawState
118
114
  : createSessionState();
119
115
 
120
- // Load file index
121
- const rawIndex = safeReadJson(fileIndexPath(cwd));
122
- const index: FileIndex = isFileIndex(rawIndex) ? rawIndex : createEmptyIndex();
116
+ // File index repository — one-key lookup, no whole-index load.
117
+ const repo = FileIndexRepo.for(cwd);
123
118
 
124
- const result = analyzePostWrite(filePath, fileContent, index);
119
+ const result = analyzePostWrite(filePath, fileContent, repo);
125
120
 
126
121
  if (result.excluded) return;
127
122
 
128
- // 1. File index update
123
+ // 1. File index update — single-row upsert.
129
124
  if (result.indexEntry) {
130
- upsertEntry(index, result.indexEntry);
125
+ repo.upsert(result.indexEntry);
131
126
  }
132
127
 
133
128
  // 2. Action log entry — write to this device's shard
@@ -149,9 +144,8 @@ export async function postWrite(cwd: string): Promise<void> {
149
144
  // 3. Session state update
150
145
  recordWrite(state, filePath, result.action, result.estimatedTokens);
151
146
 
152
- // Persist state changes
147
+ // Persist session — file index already committed via repo.upsert.
153
148
  atomicWriteJson(sessionPath(cwd), state);
154
- atomicWriteJson(fileIndexPath(cwd), index);
155
149
  } catch {
156
150
  // Never crash — exit silently
157
151
  } finally {
@@ -1,15 +1,12 @@
1
1
  import { relative } from "path";
2
2
  import { readStdinJson } from "../core/stdin";
3
- import { sessionPath, fileIndexPath } from "../core/paths";
3
+ import { sessionPath } from "../core/paths";
4
4
  import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
5
5
  import { createSessionState, isSessionState } from "../core/session";
6
- import { isFileIndex, lookupEntry } from "../core/index-store";
7
- import {
8
- incrementFileIndexHit,
9
- incrementFileIndexMiss,
10
- } from "../core/state-counters";
6
+ import { FileIndexRepo } from "../repositories/file-index-repo";
7
+ import { CountersRepo } from "../repositories/counters-repo";
11
8
  import type { SessionState } from "../types/session";
12
- import type { FileIndex, FileIndexEntry } from "../types/file-index";
9
+ import type { FileIndexEntry, IndexLookup } from "../types/file-index";
13
10
  import type { PreToolUseInput } from "../types/hook-input";
14
11
 
15
12
  export interface PreReadResult {
@@ -22,7 +19,7 @@ export interface PreReadResult {
22
19
  export function analyzePreRead(
23
20
  filePath: string,
24
21
  state: SessionState,
25
- index: FileIndex | null
22
+ index: IndexLookup | null
26
23
  ): PreReadResult {
27
24
  const warnings: string[] = [];
28
25
  let repeatedRead = false;
@@ -39,12 +36,12 @@ export function analyzePreRead(
39
36
  state.counters.repeatedReadWarnings++;
40
37
  }
41
38
 
42
- // File index lookup. Hit/miss telemetry is persisted by the caller via
43
- // increment{Hit,Miss}, not by mutating the shared index — keeping the
44
- // file-index.json content-addressed by filePath so a JSON union merge driver
45
- // can resolve cross-device updates without conflict.
39
+ // File index lookup. Hit/miss telemetry is persisted by the caller into
40
+ // the counters table, not by mutating the index — keeps the file_index
41
+ // row's last_modified stable so the sync merge driver doesn't churn it
42
+ // on every read.
46
43
  if (index) {
47
- entry = lookupEntry(index, filePath);
44
+ entry = index.lookupEntry(filePath);
48
45
  if (entry) {
49
46
  indexHit = true;
50
47
  warnings.push(
@@ -84,11 +81,10 @@ export async function preRead(cwd: string): Promise<void> {
84
81
  ? rawState
85
82
  : createSessionState();
86
83
 
87
- // Load file index (null if missing/corrupt)
88
- const rawIndex = safeReadJson(fileIndexPath(cwd));
89
- const index: FileIndex | null = isFileIndex(rawIndex) ? rawIndex : null;
84
+ // File index repository one-key lookup per hook.
85
+ const repo = FileIndexRepo.for(cwd);
90
86
 
91
- const result = analyzePreRead(filePath, state, index);
87
+ const result = analyzePreRead(filePath, state, repo);
92
88
 
93
89
  // Emit warnings to stderr
94
90
  for (const warning of result.warnings) {
@@ -97,13 +93,12 @@ export async function preRead(cwd: string): Promise<void> {
97
93
 
98
94
  // Persist state changes
99
95
  atomicWriteJson(sessionPath(cwd), state);
100
- if (index) {
101
- try {
102
- if (result.indexHit) incrementFileIndexHit(cwd);
103
- else incrementFileIndexMiss(cwd);
104
- } catch {
105
- // Counter file is best-effort telemetry — never block the read hook
106
- }
96
+ try {
97
+ const counters = CountersRepo.for(cwd);
98
+ if (result.indexHit) counters.incrementHit();
99
+ else counters.incrementMiss();
100
+ } catch {
101
+ // Counter table is best-effort telemetry — never block the read hook
107
102
  }
108
103
  } catch {
109
104
  // Never crash — exit silently