@threadbase-sh/scanner 0.8.4 → 0.9.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/dist/index.cjs CHANGED
@@ -1279,33 +1279,33 @@ function fingerprint(filePath, size) {
1279
1279
  return hash.digest("hex");
1280
1280
  }
1281
1281
  function classify(filePath, existing) {
1282
- let stat3;
1282
+ let stat4;
1283
1283
  try {
1284
1284
  const s = (0, import_fs5.statSync)(filePath);
1285
- stat3 = { size: s.size, mtimeMs: s.mtimeMs };
1285
+ stat4 = { size: s.size, mtimeMs: s.mtimeMs };
1286
1286
  } catch {
1287
1287
  return { change: "vanished" };
1288
1288
  }
1289
1289
  if (!existing || existing.status !== "active" || existing.last_indexed_offset === 0) {
1290
- return { change: "reindex", stat: stat3 };
1290
+ return { change: "reindex", stat: stat4 };
1291
1291
  }
1292
- if (stat3.size < existing.last_indexed_offset) {
1293
- return { change: "reindex", stat: stat3 };
1292
+ if (stat4.size < existing.last_indexed_offset) {
1293
+ return { change: "reindex", stat: stat4 };
1294
1294
  }
1295
- if (stat3.size === existing.size_bytes && stat3.mtimeMs === existing.mtime_ms) {
1296
- return { change: "unchanged", stat: stat3 };
1295
+ if (stat4.size === existing.size_bytes && stat4.mtimeMs === existing.mtime_ms) {
1296
+ return { change: "unchanged", stat: stat4 };
1297
1297
  }
1298
- if (stat3.size === existing.last_indexed_offset) {
1299
- const fp = fingerprint(filePath, stat3.size);
1298
+ if (stat4.size === existing.last_indexed_offset) {
1299
+ const fp = fingerprint(filePath, stat4.size);
1300
1300
  if (existing.content_fingerprint && fp !== existing.content_fingerprint) {
1301
- return { change: "reindex", stat: stat3 };
1301
+ return { change: "reindex", stat: stat4 };
1302
1302
  }
1303
- return { change: "unchanged", stat: stat3 };
1303
+ return { change: "unchanged", stat: stat4 };
1304
1304
  }
1305
1305
  if (existing.content_fingerprint && existing.size_bytes === existing.last_indexed_offset && fingerprint(filePath, existing.last_indexed_offset) === existing.content_fingerprint) {
1306
- return { change: "appended", stat: stat3 };
1306
+ return { change: "appended", stat: stat4 };
1307
1307
  }
1308
- return { change: "reindex", stat: stat3 };
1308
+ return { change: "reindex", stat: stat4 };
1309
1309
  }
1310
1310
 
1311
1311
  // src/providers/parse.ts
@@ -1495,6 +1495,24 @@ CREATE TABLE IF NOT EXISTS message_checkpoints (
1495
1495
 
1496
1496
  CREATE INDEX IF NOT EXISTS idx_message_checkpoints_lookup
1497
1497
  ON message_checkpoints(source_path, message_index);
1498
+
1499
+ -- Per-directory mtime watermark for the discovery dir-mtime gate (skips the
1500
+ -- glob for a directory whose file/subdir SET hasn't changed; never skips
1501
+ -- per-file classify()). One row for a profile's projectsDir itself (parent_root
1502
+ -- IS NULL) and one row per immediate project subdirectory discovered under it
1503
+ -- (parent_root = the projectsDir path). has_nested marks a project dir known to
1504
+ -- contain files below its own top level (e.g. subagents/) \u2014 those always
1505
+ -- re-glob regardless of mtime, since a project-dir-level watermark can't see a
1506
+ -- change two levels down.
1507
+ CREATE TABLE IF NOT EXISTS scanned_dirs (
1508
+ path TEXT PRIMARY KEY,
1509
+ parent_root TEXT,
1510
+ mtime_ms INTEGER NOT NULL,
1511
+ has_nested INTEGER NOT NULL DEFAULT 0,
1512
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
1513
+ );
1514
+
1515
+ CREATE INDEX IF NOT EXISTS idx_scanned_dirs_parent_root ON scanned_dirs(parent_root);
1498
1516
  `;
1499
1517
 
1500
1518
  // src/persistent/migrations.ts
@@ -1545,6 +1563,82 @@ function openDatabase(dbPath) {
1545
1563
  return db;
1546
1564
  }
1547
1565
 
1566
+ // src/persistent/dir-watermark.ts
1567
+ var import_promises4 = require("fs/promises");
1568
+ var FULL_RECONCILE_EVERY_N_SCANS = 20;
1569
+ async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
1570
+ const log = getLogger();
1571
+ if (options.fullRescan) {
1572
+ return discoverJsonlFiles(dirs);
1573
+ }
1574
+ const results = [];
1575
+ for (const rawDir of dirs) {
1576
+ const { account } = rawDir;
1577
+ const projectsDir = rawDir.projectsDir.replace(/\\/g, "/");
1578
+ const resolved = await resolveProjectDirs(projectsDir, scannedDirs);
1579
+ if (resolved === null) continue;
1580
+ for (const projectDir of resolved.entries) {
1581
+ let dirStat;
1582
+ try {
1583
+ dirStat = await (0, import_promises4.stat)(projectDir);
1584
+ } catch {
1585
+ scannedDirs.remove(projectDir);
1586
+ continue;
1587
+ }
1588
+ const watermark = scannedDirs.get(projectDir);
1589
+ const canReuse = watermark !== void 0 && watermark.mtime_ms === dirStat.mtimeMs && watermark.has_nested === 0;
1590
+ if (canReuse) {
1591
+ for (const row of files.activePathsByParentDir(projectDir)) {
1592
+ results.push({ filePath: row.absolute_path, account: row.account });
1593
+ }
1594
+ continue;
1595
+ }
1596
+ const found = await discoverJsonlFiles([{ projectsDir: projectDir, account }]);
1597
+ const hasNested = found.some((f) => dirnameOf(f.filePath) !== projectDir);
1598
+ scannedDirs.upsert(projectDir, projectsDir, dirStat.mtimeMs, hasNested);
1599
+ results.push(...found);
1600
+ }
1601
+ if (resolved.commitRoot) {
1602
+ scannedDirs.upsert(projectsDir, null, resolved.commitRoot.mtimeMs, false);
1603
+ const seen = new Set(resolved.entries);
1604
+ for (const known of scannedDirs.childrenOf(projectsDir)) {
1605
+ if (!seen.has(known.path)) scannedDirs.remove(known.path);
1606
+ }
1607
+ }
1608
+ }
1609
+ log.debug(
1610
+ { totalFiles: results.length, dirs: dirs.length },
1611
+ "dir-watermark: gated discovery complete"
1612
+ );
1613
+ return results;
1614
+ }
1615
+ async function resolveProjectDirs(projectsDir, scannedDirs) {
1616
+ let rootStat;
1617
+ try {
1618
+ rootStat = await (0, import_promises4.stat)(projectsDir);
1619
+ } catch {
1620
+ return null;
1621
+ }
1622
+ const rootWatermark = scannedDirs.get(projectsDir);
1623
+ if (rootWatermark !== void 0 && rootWatermark.mtime_ms === rootStat.mtimeMs) {
1624
+ return { entries: scannedDirs.childrenOf(projectsDir).map((row) => row.path) };
1625
+ }
1626
+ let entries;
1627
+ try {
1628
+ entries = (await (0, import_promises4.readdir)(projectsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => joinPath(projectsDir, e.name));
1629
+ } catch {
1630
+ return null;
1631
+ }
1632
+ return { entries, commitRoot: { mtimeMs: rootStat.mtimeMs } };
1633
+ }
1634
+ function dirnameOf(filePath) {
1635
+ const idx = filePath.lastIndexOf("/");
1636
+ return idx === -1 ? filePath : filePath.slice(0, idx);
1637
+ }
1638
+ function joinPath(dir, name) {
1639
+ return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
1640
+ }
1641
+
1548
1642
  // src/persistent/jsonl-tail-reader.ts
1549
1643
  var import_fs8 = require("fs");
1550
1644
  async function tailReduce(filePath, startOffset, startLine, state, tier) {
@@ -1751,6 +1845,14 @@ var ConversationFilesRepo = class {
1751
1845
  const rows = this.db.prepare("SELECT absolute_path FROM conversation_files WHERE status != 'deleted'").all();
1752
1846
  return rows.map((r) => r.absolute_path);
1753
1847
  }
1848
+ // Active files whose immediate parent is exactly parentDir (no nested
1849
+ // subdirectories). Backs the dir-mtime gate's reuse path: a project dir with
1850
+ // an unchanged mtime and no nested files can skip the glob entirely.
1851
+ activePathsByParentDir(parentDir) {
1852
+ return this.db.prepare(
1853
+ "SELECT absolute_path, account FROM conversation_files WHERE parent_dir = ? AND status != 'deleted'"
1854
+ ).all(parentDir);
1855
+ }
1754
1856
  };
1755
1857
 
1756
1858
  // src/persistent/repositories/conversations.repo.ts
@@ -1999,6 +2101,36 @@ function toMatchQuery(query) {
1999
2101
  return terms.map((t) => `"${t}"*`).join(" AND ");
2000
2102
  }
2001
2103
 
2104
+ // src/persistent/repositories/scanned-dirs.repo.ts
2105
+ var ScannedDirsRepo = class {
2106
+ constructor(db) {
2107
+ this.db = db;
2108
+ }
2109
+ db;
2110
+ get(path) {
2111
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(path);
2112
+ }
2113
+ // Known project subdirectories under a root, path ascending (stable order).
2114
+ childrenOf(parentRoot) {
2115
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(parentRoot);
2116
+ }
2117
+ upsert(path, parentRoot, mtimeMs, hasNested) {
2118
+ this.db.prepare(
2119
+ `INSERT INTO scanned_dirs (path, parent_root, mtime_ms, has_nested, updated_at)
2120
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
2121
+ ON CONFLICT(path) DO UPDATE SET
2122
+ parent_root = excluded.parent_root,
2123
+ mtime_ms = excluded.mtime_ms,
2124
+ has_nested = excluded.has_nested,
2125
+ updated_at = CURRENT_TIMESTAMP`
2126
+ ).run(path, parentRoot, mtimeMs, hasNested ? 1 : 0);
2127
+ }
2128
+ // Drop a project subdirectory's watermark row (it vanished from disk).
2129
+ remove(path) {
2130
+ this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(path);
2131
+ }
2132
+ };
2133
+
2002
2134
  // src/persistent/index-engine.ts
2003
2135
  var BATCH_SIZE = 12;
2004
2136
  var PersistentEngine = class {
@@ -2007,15 +2139,22 @@ var PersistentEngine = class {
2007
2139
  conversations;
2008
2140
  fts;
2009
2141
  checkpoints;
2142
+ scannedDirs;
2010
2143
  // When true, write a portable <file>.idx.json sidecar next to each indexed
2011
2144
  // JSONL. Off by default.
2012
2145
  sidecar;
2146
+ // Counts indexAll() passes so the dir-mtime gate's full-reconcile backstop
2147
+ // (FULL_RECONCILE_EVERY_N_SCANS) can fire periodically. In-memory only: a
2148
+ // restart just means the first few post-restart scans don't force an early
2149
+ // backstop pass, which is harmless (watermarks themselves persist in the DB).
2150
+ scanCount = 0;
2013
2151
  constructor(dbPath, options = {}) {
2014
2152
  this.db = openDatabase(dbPath);
2015
2153
  this.files = new ConversationFilesRepo(this.db);
2016
2154
  this.conversations = new ConversationsRepo(this.db);
2017
2155
  this.fts = new FtsRepo(this.db);
2018
2156
  this.checkpoints = new CheckpointsRepo(this.db);
2157
+ this.scannedDirs = new ScannedDirsRepo(this.db);
2019
2158
  this.sidecar = options.sidecar ?? false;
2020
2159
  }
2021
2160
  close() {
@@ -2034,7 +2173,12 @@ var PersistentEngine = class {
2034
2173
  projectsDir: getProjectsDir(p),
2035
2174
  account: p.id
2036
2175
  }));
2037
- for (const f of await discoverJsonlFiles(configDirs)) discovered.push(f);
2176
+ this.scanCount++;
2177
+ const forceFullGlob = options.fullRescan === true || this.scanCount % FULL_RECONCILE_EVERY_N_SCANS === 0;
2178
+ const gated = await discoverJsonlFilesGated(configDirs, this.files, this.scannedDirs, {
2179
+ fullRescan: forceFullGlob
2180
+ });
2181
+ for (const f of gated) discovered.push(f);
2038
2182
  }
2039
2183
  const codex = new CodexCliProvider();
2040
2184
  if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
@@ -2089,8 +2233,8 @@ var PersistentEngine = class {
2089
2233
  const log = getLogger();
2090
2234
  const tier = resolveTier(tierName, customTiers);
2091
2235
  const existing = this.files.getByPath(filePath);
2092
- const { change, stat: stat3 } = classify(filePath, existing);
2093
- if (change === "vanished" || !stat3) {
2236
+ const { change, stat: stat4 } = classify(filePath, existing);
2237
+ if (change === "vanished" || !stat4) {
2094
2238
  this.markDeleted(filePath);
2095
2239
  return null;
2096
2240
  }
@@ -2098,7 +2242,7 @@ var PersistentEngine = class {
2098
2242
  return this.conversations.getBySourcePath(filePath);
2099
2243
  }
2100
2244
  if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
2101
- return this.indexFileWithProvider(provider, filePath, account, tier, stat3, resolveGitBranch);
2245
+ return this.indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch);
2102
2246
  }
2103
2247
  const resume = change === "appended" && !force && existing?.reducer_state;
2104
2248
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
@@ -2117,15 +2261,15 @@ var PersistentEngine = class {
2117
2261
  return null;
2118
2262
  }
2119
2263
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2120
- const fp = stat3.size > 0 ? fingerprint(filePath, stat3.size) : null;
2264
+ const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2121
2265
  const fileId = this.files.ensure(filePath, account);
2122
2266
  const upsert = this.db.transaction(() => {
2123
2267
  this.conversations.upsert(fileId, meta, state.pageMessageCount);
2124
2268
  this.fts.upsert(meta);
2125
2269
  this.checkpoints.remove(filePath);
2126
2270
  this.files.updateCursor(fileId, {
2127
- sizeBytes: stat3.size,
2128
- mtimeMs: stat3.mtimeMs,
2271
+ sizeBytes: stat4.size,
2272
+ mtimeMs: stat4.mtimeMs,
2129
2273
  // Advance only to the last fully-parsed line; a trailing partial line
2130
2274
  // is left for the next pass.
2131
2275
  offset: result.newOffset,
@@ -2142,8 +2286,8 @@ var PersistentEngine = class {
2142
2286
  buildSidecar(
2143
2287
  meta,
2144
2288
  {
2145
- sizeBytes: stat3.size,
2146
- mtimeMs: stat3.mtimeMs,
2289
+ sizeBytes: stat4.size,
2290
+ mtimeMs: stat4.mtimeMs,
2147
2291
  offset: result.newOffset,
2148
2292
  line: result.newLine
2149
2293
  },
@@ -2162,7 +2306,7 @@ var PersistentEngine = class {
2162
2306
  // the Threadbase path uses. The cursor records size/mtime (and offset = size)
2163
2307
  // so the next pass classifies an unchanged file as "unchanged" and skips it;
2164
2308
  // any change reparses from 0 again. No reducer_state is persisted.
2165
- async indexFileWithProvider(provider, filePath, account, tier, stat3, resolveGitBranch) {
2309
+ async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
2166
2310
  const log = getLogger();
2167
2311
  const meta = await parseMetaWithProvider(provider, filePath, account, tier);
2168
2312
  if (!meta) {
@@ -2172,18 +2316,18 @@ var PersistentEngine = class {
2172
2316
  if (meta.gitBranch === null && meta.projectPath) {
2173
2317
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2174
2318
  }
2175
- const fp = stat3.size > 0 ? fingerprint(filePath, stat3.size) : null;
2319
+ const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2176
2320
  const fileId = this.files.ensure(filePath, account);
2177
2321
  const upsert = this.db.transaction(() => {
2178
2322
  this.conversations.upsert(fileId, meta, meta.messageCount);
2179
2323
  this.fts.upsert(meta);
2180
2324
  this.checkpoints.remove(filePath);
2181
2325
  this.files.updateCursor(fileId, {
2182
- sizeBytes: stat3.size,
2183
- mtimeMs: stat3.mtimeMs,
2326
+ sizeBytes: stat4.size,
2327
+ mtimeMs: stat4.mtimeMs,
2184
2328
  // offset = size marks the file fully consumed (non-zero so the next pass
2185
2329
  // can classify it "unchanged"); no resumable reducer state is kept.
2186
- offset: stat3.size,
2330
+ offset: stat4.size,
2187
2331
  line: 0,
2188
2332
  reducerState: null,
2189
2333
  fingerprint: fp,