@threadbase-sh/scanner 0.8.4 → 0.9.1

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/dist/index.d.cts CHANGED
@@ -83,6 +83,11 @@ interface ScanOptions {
83
83
  stat: FileStatEntry;
84
84
  meta: ConversationMeta;
85
85
  }>;
86
+ /** Persistent mode only: bypass the dir-mtime discovery gate and force a
87
+ * full glob of every profile's projectsDir, ignoring stored watermarks.
88
+ * Use for an explicit user-triggered refresh that shouldn't trust the
89
+ * gate's optimization. */
90
+ fullRescan?: boolean;
86
91
  }
87
92
  interface ScanResult {
88
93
  conversations: ConversationMeta[] | TreeConversation[] | GroupedConversations;
package/dist/index.d.ts CHANGED
@@ -83,6 +83,11 @@ interface ScanOptions {
83
83
  stat: FileStatEntry;
84
84
  meta: ConversationMeta;
85
85
  }>;
86
+ /** Persistent mode only: bypass the dir-mtime discovery gate and force a
87
+ * full glob of every profile's projectsDir, ignoring stored watermarks.
88
+ * Use for an explicit user-triggered refresh that shouldn't trust the
89
+ * gate's optimization. */
90
+ fullRescan?: boolean;
86
91
  }
87
92
  interface ScanResult {
88
93
  conversations: ConversationMeta[] | TreeConversation[] | GroupedConversations;
package/dist/index.js CHANGED
@@ -1215,33 +1215,33 @@ function fingerprint(filePath, size) {
1215
1215
  return hash.digest("hex");
1216
1216
  }
1217
1217
  function classify(filePath, existing) {
1218
- let stat3;
1218
+ let stat4;
1219
1219
  try {
1220
1220
  const s = statSync(filePath);
1221
- stat3 = { size: s.size, mtimeMs: s.mtimeMs };
1221
+ stat4 = { size: s.size, mtimeMs: s.mtimeMs };
1222
1222
  } catch {
1223
1223
  return { change: "vanished" };
1224
1224
  }
1225
1225
  if (!existing || existing.status !== "active" || existing.last_indexed_offset === 0) {
1226
- return { change: "reindex", stat: stat3 };
1226
+ return { change: "reindex", stat: stat4 };
1227
1227
  }
1228
- if (stat3.size < existing.last_indexed_offset) {
1229
- return { change: "reindex", stat: stat3 };
1228
+ if (stat4.size < existing.last_indexed_offset) {
1229
+ return { change: "reindex", stat: stat4 };
1230
1230
  }
1231
- if (stat3.size === existing.size_bytes && stat3.mtimeMs === existing.mtime_ms) {
1232
- return { change: "unchanged", stat: stat3 };
1231
+ if (stat4.size === existing.size_bytes && stat4.mtimeMs === existing.mtime_ms) {
1232
+ return { change: "unchanged", stat: stat4 };
1233
1233
  }
1234
- if (stat3.size === existing.last_indexed_offset) {
1235
- const fp = fingerprint(filePath, stat3.size);
1234
+ if (stat4.size === existing.last_indexed_offset) {
1235
+ const fp = fingerprint(filePath, stat4.size);
1236
1236
  if (existing.content_fingerprint && fp !== existing.content_fingerprint) {
1237
- return { change: "reindex", stat: stat3 };
1237
+ return { change: "reindex", stat: stat4 };
1238
1238
  }
1239
- return { change: "unchanged", stat: stat3 };
1239
+ return { change: "unchanged", stat: stat4 };
1240
1240
  }
1241
1241
  if (existing.content_fingerprint && existing.size_bytes === existing.last_indexed_offset && fingerprint(filePath, existing.last_indexed_offset) === existing.content_fingerprint) {
1242
- return { change: "appended", stat: stat3 };
1242
+ return { change: "appended", stat: stat4 };
1243
1243
  }
1244
- return { change: "reindex", stat: stat3 };
1244
+ return { change: "reindex", stat: stat4 };
1245
1245
  }
1246
1246
 
1247
1247
  // src/providers/parse.ts
@@ -1297,7 +1297,7 @@ import { mkdirSync } from "fs";
1297
1297
  import { dirname as dirname3 } from "path";
1298
1298
 
1299
1299
  // src/persistent/schema.ts
1300
- var SCHEMA_VERSION = 3;
1300
+ var SCHEMA_VERSION = 4;
1301
1301
  var SCHEMA_SQL = `
1302
1302
  CREATE TABLE IF NOT EXISTS conversation_files (
1303
1303
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1431,6 +1431,24 @@ CREATE TABLE IF NOT EXISTS message_checkpoints (
1431
1431
 
1432
1432
  CREATE INDEX IF NOT EXISTS idx_message_checkpoints_lookup
1433
1433
  ON message_checkpoints(source_path, message_index);
1434
+
1435
+ -- Per-directory mtime watermark for the discovery dir-mtime gate (skips the
1436
+ -- glob for a directory whose file/subdir SET hasn't changed; never skips
1437
+ -- per-file classify()). One row for a profile's projectsDir itself (parent_root
1438
+ -- IS NULL) and one row per immediate project subdirectory discovered under it
1439
+ -- (parent_root = the projectsDir path). has_nested marks a project dir known to
1440
+ -- contain files below its own top level (e.g. subagents/) \u2014 those always
1441
+ -- re-glob regardless of mtime, since a project-dir-level watermark can't see a
1442
+ -- change two levels down.
1443
+ CREATE TABLE IF NOT EXISTS scanned_dirs (
1444
+ path TEXT PRIMARY KEY,
1445
+ parent_root TEXT,
1446
+ mtime_ms INTEGER NOT NULL,
1447
+ has_nested INTEGER NOT NULL DEFAULT 0,
1448
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
1449
+ );
1450
+
1451
+ CREATE INDEX IF NOT EXISTS idx_scanned_dirs_parent_root ON scanned_dirs(parent_root);
1434
1452
  `;
1435
1453
 
1436
1454
  // src/persistent/migrations.ts
@@ -1481,6 +1499,82 @@ function openDatabase(dbPath) {
1481
1499
  return db;
1482
1500
  }
1483
1501
 
1502
+ // src/persistent/dir-watermark.ts
1503
+ import { readdir, stat as stat3 } from "fs/promises";
1504
+ var FULL_RECONCILE_EVERY_N_SCANS = 20;
1505
+ async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
1506
+ const log = getLogger();
1507
+ if (options.fullRescan) {
1508
+ return discoverJsonlFiles(dirs);
1509
+ }
1510
+ const results = [];
1511
+ for (const rawDir of dirs) {
1512
+ const { account } = rawDir;
1513
+ const projectsDir = rawDir.projectsDir.replace(/\\/g, "/");
1514
+ const resolved = await resolveProjectDirs(projectsDir, scannedDirs);
1515
+ if (resolved === null) continue;
1516
+ for (const projectDir of resolved.entries) {
1517
+ let dirStat;
1518
+ try {
1519
+ dirStat = await stat3(projectDir);
1520
+ } catch {
1521
+ scannedDirs.remove(projectDir);
1522
+ continue;
1523
+ }
1524
+ const watermark = scannedDirs.get(projectDir);
1525
+ const canReuse = watermark !== void 0 && watermark.mtime_ms === dirStat.mtimeMs && watermark.has_nested === 0;
1526
+ if (canReuse) {
1527
+ for (const row of files.activePathsByParentDir(projectDir)) {
1528
+ results.push({ filePath: row.absolute_path, account: row.account });
1529
+ }
1530
+ continue;
1531
+ }
1532
+ const found = await discoverJsonlFiles([{ projectsDir: projectDir, account }]);
1533
+ const hasNested = found.some((f) => dirnameOf(f.filePath) !== projectDir);
1534
+ scannedDirs.upsert(projectDir, projectsDir, dirStat.mtimeMs, hasNested);
1535
+ results.push(...found);
1536
+ }
1537
+ if (resolved.commitRoot) {
1538
+ scannedDirs.upsert(projectsDir, null, resolved.commitRoot.mtimeMs, false);
1539
+ const seen = new Set(resolved.entries);
1540
+ for (const known of scannedDirs.childrenOf(projectsDir)) {
1541
+ if (!seen.has(known.path)) scannedDirs.remove(known.path);
1542
+ }
1543
+ }
1544
+ }
1545
+ log.debug(
1546
+ { totalFiles: results.length, dirs: dirs.length },
1547
+ "dir-watermark: gated discovery complete"
1548
+ );
1549
+ return results;
1550
+ }
1551
+ async function resolveProjectDirs(projectsDir, scannedDirs) {
1552
+ let rootStat;
1553
+ try {
1554
+ rootStat = await stat3(projectsDir);
1555
+ } catch {
1556
+ return null;
1557
+ }
1558
+ const rootWatermark = scannedDirs.get(projectsDir);
1559
+ if (rootWatermark !== void 0 && rootWatermark.mtime_ms === rootStat.mtimeMs) {
1560
+ return { entries: scannedDirs.childrenOf(projectsDir).map((row) => row.path) };
1561
+ }
1562
+ let entries;
1563
+ try {
1564
+ entries = (await readdir(projectsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => joinPath(projectsDir, e.name));
1565
+ } catch {
1566
+ return null;
1567
+ }
1568
+ return { entries, commitRoot: { mtimeMs: rootStat.mtimeMs } };
1569
+ }
1570
+ function dirnameOf(filePath) {
1571
+ const idx = filePath.lastIndexOf("/");
1572
+ return idx === -1 ? filePath : filePath.slice(0, idx);
1573
+ }
1574
+ function joinPath(dir, name) {
1575
+ return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
1576
+ }
1577
+
1484
1578
  // src/persistent/jsonl-tail-reader.ts
1485
1579
  import { createReadStream as createReadStream4 } from "fs";
1486
1580
  async function tailReduce(filePath, startOffset, startLine, state, tier) {
@@ -1687,6 +1781,14 @@ var ConversationFilesRepo = class {
1687
1781
  const rows = this.db.prepare("SELECT absolute_path FROM conversation_files WHERE status != 'deleted'").all();
1688
1782
  return rows.map((r) => r.absolute_path);
1689
1783
  }
1784
+ // Active files whose immediate parent is exactly parentDir (no nested
1785
+ // subdirectories). Backs the dir-mtime gate's reuse path: a project dir with
1786
+ // an unchanged mtime and no nested files can skip the glob entirely.
1787
+ activePathsByParentDir(parentDir) {
1788
+ return this.db.prepare(
1789
+ "SELECT absolute_path, account FROM conversation_files WHERE parent_dir = ? AND status != 'deleted'"
1790
+ ).all(parentDir);
1791
+ }
1690
1792
  };
1691
1793
 
1692
1794
  // src/persistent/repositories/conversations.repo.ts
@@ -1935,6 +2037,36 @@ function toMatchQuery(query) {
1935
2037
  return terms.map((t) => `"${t}"*`).join(" AND ");
1936
2038
  }
1937
2039
 
2040
+ // src/persistent/repositories/scanned-dirs.repo.ts
2041
+ var ScannedDirsRepo = class {
2042
+ constructor(db) {
2043
+ this.db = db;
2044
+ }
2045
+ db;
2046
+ get(path) {
2047
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(path);
2048
+ }
2049
+ // Known project subdirectories under a root, path ascending (stable order).
2050
+ childrenOf(parentRoot) {
2051
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(parentRoot);
2052
+ }
2053
+ upsert(path, parentRoot, mtimeMs, hasNested) {
2054
+ this.db.prepare(
2055
+ `INSERT INTO scanned_dirs (path, parent_root, mtime_ms, has_nested, updated_at)
2056
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
2057
+ ON CONFLICT(path) DO UPDATE SET
2058
+ parent_root = excluded.parent_root,
2059
+ mtime_ms = excluded.mtime_ms,
2060
+ has_nested = excluded.has_nested,
2061
+ updated_at = CURRENT_TIMESTAMP`
2062
+ ).run(path, parentRoot, mtimeMs, hasNested ? 1 : 0);
2063
+ }
2064
+ // Drop a project subdirectory's watermark row (it vanished from disk).
2065
+ remove(path) {
2066
+ this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(path);
2067
+ }
2068
+ };
2069
+
1938
2070
  // src/persistent/index-engine.ts
1939
2071
  var BATCH_SIZE = 12;
1940
2072
  var PersistentEngine = class {
@@ -1943,15 +2075,22 @@ var PersistentEngine = class {
1943
2075
  conversations;
1944
2076
  fts;
1945
2077
  checkpoints;
2078
+ scannedDirs;
1946
2079
  // When true, write a portable <file>.idx.json sidecar next to each indexed
1947
2080
  // JSONL. Off by default.
1948
2081
  sidecar;
2082
+ // Counts indexAll() passes so the dir-mtime gate's full-reconcile backstop
2083
+ // (FULL_RECONCILE_EVERY_N_SCANS) can fire periodically. In-memory only: a
2084
+ // restart just means the first few post-restart scans don't force an early
2085
+ // backstop pass, which is harmless (watermarks themselves persist in the DB).
2086
+ scanCount = 0;
1949
2087
  constructor(dbPath, options = {}) {
1950
2088
  this.db = openDatabase(dbPath);
1951
2089
  this.files = new ConversationFilesRepo(this.db);
1952
2090
  this.conversations = new ConversationsRepo(this.db);
1953
2091
  this.fts = new FtsRepo(this.db);
1954
2092
  this.checkpoints = new CheckpointsRepo(this.db);
2093
+ this.scannedDirs = new ScannedDirsRepo(this.db);
1955
2094
  this.sidecar = options.sidecar ?? false;
1956
2095
  }
1957
2096
  close() {
@@ -1970,7 +2109,12 @@ var PersistentEngine = class {
1970
2109
  projectsDir: getProjectsDir(p),
1971
2110
  account: p.id
1972
2111
  }));
1973
- for (const f of await discoverJsonlFiles(configDirs)) discovered.push(f);
2112
+ this.scanCount++;
2113
+ const forceFullGlob = options.fullRescan === true || this.scanCount % FULL_RECONCILE_EVERY_N_SCANS === 0;
2114
+ const gated = await discoverJsonlFilesGated(configDirs, this.files, this.scannedDirs, {
2115
+ fullRescan: forceFullGlob
2116
+ });
2117
+ for (const f of gated) discovered.push(f);
1974
2118
  }
1975
2119
  const codex = new CodexCliProvider();
1976
2120
  if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
@@ -2025,8 +2169,8 @@ var PersistentEngine = class {
2025
2169
  const log = getLogger();
2026
2170
  const tier = resolveTier(tierName, customTiers);
2027
2171
  const existing = this.files.getByPath(filePath);
2028
- const { change, stat: stat3 } = classify(filePath, existing);
2029
- if (change === "vanished" || !stat3) {
2172
+ const { change, stat: stat4 } = classify(filePath, existing);
2173
+ if (change === "vanished" || !stat4) {
2030
2174
  this.markDeleted(filePath);
2031
2175
  return null;
2032
2176
  }
@@ -2034,7 +2178,7 @@ var PersistentEngine = class {
2034
2178
  return this.conversations.getBySourcePath(filePath);
2035
2179
  }
2036
2180
  if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
2037
- return this.indexFileWithProvider(provider, filePath, account, tier, stat3, resolveGitBranch);
2181
+ return this.indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch);
2038
2182
  }
2039
2183
  const resume = change === "appended" && !force && existing?.reducer_state;
2040
2184
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
@@ -2053,15 +2197,15 @@ var PersistentEngine = class {
2053
2197
  return null;
2054
2198
  }
2055
2199
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2056
- const fp = stat3.size > 0 ? fingerprint(filePath, stat3.size) : null;
2200
+ const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2057
2201
  const fileId = this.files.ensure(filePath, account);
2058
2202
  const upsert = this.db.transaction(() => {
2059
2203
  this.conversations.upsert(fileId, meta, state.pageMessageCount);
2060
2204
  this.fts.upsert(meta);
2061
2205
  this.checkpoints.remove(filePath);
2062
2206
  this.files.updateCursor(fileId, {
2063
- sizeBytes: stat3.size,
2064
- mtimeMs: stat3.mtimeMs,
2207
+ sizeBytes: stat4.size,
2208
+ mtimeMs: stat4.mtimeMs,
2065
2209
  // Advance only to the last fully-parsed line; a trailing partial line
2066
2210
  // is left for the next pass.
2067
2211
  offset: result.newOffset,
@@ -2078,8 +2222,8 @@ var PersistentEngine = class {
2078
2222
  buildSidecar(
2079
2223
  meta,
2080
2224
  {
2081
- sizeBytes: stat3.size,
2082
- mtimeMs: stat3.mtimeMs,
2225
+ sizeBytes: stat4.size,
2226
+ mtimeMs: stat4.mtimeMs,
2083
2227
  offset: result.newOffset,
2084
2228
  line: result.newLine
2085
2229
  },
@@ -2098,7 +2242,7 @@ var PersistentEngine = class {
2098
2242
  // the Threadbase path uses. The cursor records size/mtime (and offset = size)
2099
2243
  // so the next pass classifies an unchanged file as "unchanged" and skips it;
2100
2244
  // any change reparses from 0 again. No reducer_state is persisted.
2101
- async indexFileWithProvider(provider, filePath, account, tier, stat3, resolveGitBranch) {
2245
+ async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
2102
2246
  const log = getLogger();
2103
2247
  const meta = await parseMetaWithProvider(provider, filePath, account, tier);
2104
2248
  if (!meta) {
@@ -2108,18 +2252,18 @@ var PersistentEngine = class {
2108
2252
  if (meta.gitBranch === null && meta.projectPath) {
2109
2253
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2110
2254
  }
2111
- const fp = stat3.size > 0 ? fingerprint(filePath, stat3.size) : null;
2255
+ const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2112
2256
  const fileId = this.files.ensure(filePath, account);
2113
2257
  const upsert = this.db.transaction(() => {
2114
2258
  this.conversations.upsert(fileId, meta, meta.messageCount);
2115
2259
  this.fts.upsert(meta);
2116
2260
  this.checkpoints.remove(filePath);
2117
2261
  this.files.updateCursor(fileId, {
2118
- sizeBytes: stat3.size,
2119
- mtimeMs: stat3.mtimeMs,
2262
+ sizeBytes: stat4.size,
2263
+ mtimeMs: stat4.mtimeMs,
2120
2264
  // offset = size marks the file fully consumed (non-zero so the next pass
2121
2265
  // can classify it "unchanged"); no resumable reducer state is kept.
2122
- offset: stat3.size,
2266
+ offset: stat4.size,
2123
2267
  line: 0,
2124
2268
  reducerState: null,
2125
2269
  fingerprint: fp,