@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/cli.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import pino2 from "pino";
6
6
 
7
7
  // package.json
8
- var version = "0.8.4";
8
+ var version = "0.9.1";
9
9
 
10
10
  // src/logger.ts
11
11
  import pino from "pino";
@@ -909,33 +909,33 @@ function fingerprint(filePath, size) {
909
909
  return hash.digest("hex");
910
910
  }
911
911
  function classify(filePath, existing) {
912
- let stat3;
912
+ let stat4;
913
913
  try {
914
914
  const s = statSync(filePath);
915
- stat3 = { size: s.size, mtimeMs: s.mtimeMs };
915
+ stat4 = { size: s.size, mtimeMs: s.mtimeMs };
916
916
  } catch {
917
917
  return { change: "vanished" };
918
918
  }
919
919
  if (!existing || existing.status !== "active" || existing.last_indexed_offset === 0) {
920
- return { change: "reindex", stat: stat3 };
920
+ return { change: "reindex", stat: stat4 };
921
921
  }
922
- if (stat3.size < existing.last_indexed_offset) {
923
- return { change: "reindex", stat: stat3 };
922
+ if (stat4.size < existing.last_indexed_offset) {
923
+ return { change: "reindex", stat: stat4 };
924
924
  }
925
- if (stat3.size === existing.size_bytes && stat3.mtimeMs === existing.mtime_ms) {
926
- return { change: "unchanged", stat: stat3 };
925
+ if (stat4.size === existing.size_bytes && stat4.mtimeMs === existing.mtime_ms) {
926
+ return { change: "unchanged", stat: stat4 };
927
927
  }
928
- if (stat3.size === existing.last_indexed_offset) {
929
- const fp = fingerprint(filePath, stat3.size);
928
+ if (stat4.size === existing.last_indexed_offset) {
929
+ const fp = fingerprint(filePath, stat4.size);
930
930
  if (existing.content_fingerprint && fp !== existing.content_fingerprint) {
931
- return { change: "reindex", stat: stat3 };
931
+ return { change: "reindex", stat: stat4 };
932
932
  }
933
- return { change: "unchanged", stat: stat3 };
933
+ return { change: "unchanged", stat: stat4 };
934
934
  }
935
935
  if (existing.content_fingerprint && existing.size_bytes === existing.last_indexed_offset && fingerprint(filePath, existing.last_indexed_offset) === existing.content_fingerprint) {
936
- return { change: "appended", stat: stat3 };
936
+ return { change: "appended", stat: stat4 };
937
937
  }
938
- return { change: "reindex", stat: stat3 };
938
+ return { change: "reindex", stat: stat4 };
939
939
  }
940
940
 
941
941
  // src/providers/codex-cli.ts
@@ -1220,7 +1220,7 @@ import { mkdirSync } from "fs";
1220
1220
  import { dirname as dirname3 } from "path";
1221
1221
 
1222
1222
  // src/persistent/schema.ts
1223
- var SCHEMA_VERSION = 3;
1223
+ var SCHEMA_VERSION = 4;
1224
1224
  var SCHEMA_SQL = `
1225
1225
  CREATE TABLE IF NOT EXISTS conversation_files (
1226
1226
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1354,6 +1354,24 @@ CREATE TABLE IF NOT EXISTS message_checkpoints (
1354
1354
 
1355
1355
  CREATE INDEX IF NOT EXISTS idx_message_checkpoints_lookup
1356
1356
  ON message_checkpoints(source_path, message_index);
1357
+
1358
+ -- Per-directory mtime watermark for the discovery dir-mtime gate (skips the
1359
+ -- glob for a directory whose file/subdir SET hasn't changed; never skips
1360
+ -- per-file classify()). One row for a profile's projectsDir itself (parent_root
1361
+ -- IS NULL) and one row per immediate project subdirectory discovered under it
1362
+ -- (parent_root = the projectsDir path). has_nested marks a project dir known to
1363
+ -- contain files below its own top level (e.g. subagents/) \u2014 those always
1364
+ -- re-glob regardless of mtime, since a project-dir-level watermark can't see a
1365
+ -- change two levels down.
1366
+ CREATE TABLE IF NOT EXISTS scanned_dirs (
1367
+ path TEXT PRIMARY KEY,
1368
+ parent_root TEXT,
1369
+ mtime_ms INTEGER NOT NULL,
1370
+ has_nested INTEGER NOT NULL DEFAULT 0,
1371
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
1372
+ );
1373
+
1374
+ CREATE INDEX IF NOT EXISTS idx_scanned_dirs_parent_root ON scanned_dirs(parent_root);
1357
1375
  `;
1358
1376
 
1359
1377
  // src/persistent/migrations.ts
@@ -1404,6 +1422,82 @@ function openDatabase(dbPath) {
1404
1422
  return db;
1405
1423
  }
1406
1424
 
1425
+ // src/persistent/dir-watermark.ts
1426
+ import { readdir, stat as stat3 } from "fs/promises";
1427
+ var FULL_RECONCILE_EVERY_N_SCANS = 20;
1428
+ async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
1429
+ const log = getLogger();
1430
+ if (options.fullRescan) {
1431
+ return discoverJsonlFiles(dirs);
1432
+ }
1433
+ const results = [];
1434
+ for (const rawDir of dirs) {
1435
+ const { account } = rawDir;
1436
+ const projectsDir = rawDir.projectsDir.replace(/\\/g, "/");
1437
+ const resolved = await resolveProjectDirs(projectsDir, scannedDirs);
1438
+ if (resolved === null) continue;
1439
+ for (const projectDir of resolved.entries) {
1440
+ let dirStat;
1441
+ try {
1442
+ dirStat = await stat3(projectDir);
1443
+ } catch {
1444
+ scannedDirs.remove(projectDir);
1445
+ continue;
1446
+ }
1447
+ const watermark = scannedDirs.get(projectDir);
1448
+ const canReuse = watermark !== void 0 && watermark.mtime_ms === dirStat.mtimeMs && watermark.has_nested === 0;
1449
+ if (canReuse) {
1450
+ for (const row of files.activePathsByParentDir(projectDir)) {
1451
+ results.push({ filePath: row.absolute_path, account: row.account });
1452
+ }
1453
+ continue;
1454
+ }
1455
+ const found = await discoverJsonlFiles([{ projectsDir: projectDir, account }]);
1456
+ const hasNested = found.some((f) => dirnameOf(f.filePath) !== projectDir);
1457
+ scannedDirs.upsert(projectDir, projectsDir, dirStat.mtimeMs, hasNested);
1458
+ results.push(...found);
1459
+ }
1460
+ if (resolved.commitRoot) {
1461
+ scannedDirs.upsert(projectsDir, null, resolved.commitRoot.mtimeMs, false);
1462
+ const seen = new Set(resolved.entries);
1463
+ for (const known of scannedDirs.childrenOf(projectsDir)) {
1464
+ if (!seen.has(known.path)) scannedDirs.remove(known.path);
1465
+ }
1466
+ }
1467
+ }
1468
+ log.debug(
1469
+ { totalFiles: results.length, dirs: dirs.length },
1470
+ "dir-watermark: gated discovery complete"
1471
+ );
1472
+ return results;
1473
+ }
1474
+ async function resolveProjectDirs(projectsDir, scannedDirs) {
1475
+ let rootStat;
1476
+ try {
1477
+ rootStat = await stat3(projectsDir);
1478
+ } catch {
1479
+ return null;
1480
+ }
1481
+ const rootWatermark = scannedDirs.get(projectsDir);
1482
+ if (rootWatermark !== void 0 && rootWatermark.mtime_ms === rootStat.mtimeMs) {
1483
+ return { entries: scannedDirs.childrenOf(projectsDir).map((row) => row.path) };
1484
+ }
1485
+ let entries;
1486
+ try {
1487
+ entries = (await readdir(projectsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => joinPath(projectsDir, e.name));
1488
+ } catch {
1489
+ return null;
1490
+ }
1491
+ return { entries, commitRoot: { mtimeMs: rootStat.mtimeMs } };
1492
+ }
1493
+ function dirnameOf(filePath) {
1494
+ const idx = filePath.lastIndexOf("/");
1495
+ return idx === -1 ? filePath : filePath.slice(0, idx);
1496
+ }
1497
+ function joinPath(dir, name) {
1498
+ return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
1499
+ }
1500
+
1407
1501
  // src/persistent/jsonl-tail-reader.ts
1408
1502
  import { createReadStream as createReadStream4 } from "fs";
1409
1503
  async function tailReduce(filePath, startOffset, startLine, state, tier) {
@@ -1610,6 +1704,14 @@ var ConversationFilesRepo = class {
1610
1704
  const rows = this.db.prepare("SELECT absolute_path FROM conversation_files WHERE status != 'deleted'").all();
1611
1705
  return rows.map((r) => r.absolute_path);
1612
1706
  }
1707
+ // Active files whose immediate parent is exactly parentDir (no nested
1708
+ // subdirectories). Backs the dir-mtime gate's reuse path: a project dir with
1709
+ // an unchanged mtime and no nested files can skip the glob entirely.
1710
+ activePathsByParentDir(parentDir) {
1711
+ return this.db.prepare(
1712
+ "SELECT absolute_path, account FROM conversation_files WHERE parent_dir = ? AND status != 'deleted'"
1713
+ ).all(parentDir);
1714
+ }
1613
1715
  };
1614
1716
 
1615
1717
  // src/persistent/repositories/conversations.repo.ts
@@ -1858,6 +1960,36 @@ function toMatchQuery(query) {
1858
1960
  return terms.map((t) => `"${t}"*`).join(" AND ");
1859
1961
  }
1860
1962
 
1963
+ // src/persistent/repositories/scanned-dirs.repo.ts
1964
+ var ScannedDirsRepo = class {
1965
+ constructor(db) {
1966
+ this.db = db;
1967
+ }
1968
+ db;
1969
+ get(path) {
1970
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(path);
1971
+ }
1972
+ // Known project subdirectories under a root, path ascending (stable order).
1973
+ childrenOf(parentRoot) {
1974
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(parentRoot);
1975
+ }
1976
+ upsert(path, parentRoot, mtimeMs, hasNested) {
1977
+ this.db.prepare(
1978
+ `INSERT INTO scanned_dirs (path, parent_root, mtime_ms, has_nested, updated_at)
1979
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
1980
+ ON CONFLICT(path) DO UPDATE SET
1981
+ parent_root = excluded.parent_root,
1982
+ mtime_ms = excluded.mtime_ms,
1983
+ has_nested = excluded.has_nested,
1984
+ updated_at = CURRENT_TIMESTAMP`
1985
+ ).run(path, parentRoot, mtimeMs, hasNested ? 1 : 0);
1986
+ }
1987
+ // Drop a project subdirectory's watermark row (it vanished from disk).
1988
+ remove(path) {
1989
+ this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(path);
1990
+ }
1991
+ };
1992
+
1861
1993
  // src/persistent/sidecar.ts
1862
1994
  import { readFileSync as readFileSync2, writeFileSync } from "fs";
1863
1995
  var SIDECAR_VERSION = 1;
@@ -1899,15 +2031,22 @@ var PersistentEngine = class {
1899
2031
  conversations;
1900
2032
  fts;
1901
2033
  checkpoints;
2034
+ scannedDirs;
1902
2035
  // When true, write a portable <file>.idx.json sidecar next to each indexed
1903
2036
  // JSONL. Off by default.
1904
2037
  sidecar;
2038
+ // Counts indexAll() passes so the dir-mtime gate's full-reconcile backstop
2039
+ // (FULL_RECONCILE_EVERY_N_SCANS) can fire periodically. In-memory only: a
2040
+ // restart just means the first few post-restart scans don't force an early
2041
+ // backstop pass, which is harmless (watermarks themselves persist in the DB).
2042
+ scanCount = 0;
1905
2043
  constructor(dbPath, options = {}) {
1906
2044
  this.db = openDatabase(dbPath);
1907
2045
  this.files = new ConversationFilesRepo(this.db);
1908
2046
  this.conversations = new ConversationsRepo(this.db);
1909
2047
  this.fts = new FtsRepo(this.db);
1910
2048
  this.checkpoints = new CheckpointsRepo(this.db);
2049
+ this.scannedDirs = new ScannedDirsRepo(this.db);
1911
2050
  this.sidecar = options.sidecar ?? false;
1912
2051
  }
1913
2052
  close() {
@@ -1926,7 +2065,12 @@ var PersistentEngine = class {
1926
2065
  projectsDir: getProjectsDir(p),
1927
2066
  account: p.id
1928
2067
  }));
1929
- for (const f of await discoverJsonlFiles(configDirs)) discovered.push(f);
2068
+ this.scanCount++;
2069
+ const forceFullGlob = options.fullRescan === true || this.scanCount % FULL_RECONCILE_EVERY_N_SCANS === 0;
2070
+ const gated = await discoverJsonlFilesGated(configDirs, this.files, this.scannedDirs, {
2071
+ fullRescan: forceFullGlob
2072
+ });
2073
+ for (const f of gated) discovered.push(f);
1930
2074
  }
1931
2075
  const codex = new CodexCliProvider();
1932
2076
  if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
@@ -1981,8 +2125,8 @@ var PersistentEngine = class {
1981
2125
  const log = getLogger();
1982
2126
  const tier = resolveTier(tierName, customTiers);
1983
2127
  const existing = this.files.getByPath(filePath);
1984
- const { change, stat: stat3 } = classify(filePath, existing);
1985
- if (change === "vanished" || !stat3) {
2128
+ const { change, stat: stat4 } = classify(filePath, existing);
2129
+ if (change === "vanished" || !stat4) {
1986
2130
  this.markDeleted(filePath);
1987
2131
  return null;
1988
2132
  }
@@ -1990,7 +2134,7 @@ var PersistentEngine = class {
1990
2134
  return this.conversations.getBySourcePath(filePath);
1991
2135
  }
1992
2136
  if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
1993
- return this.indexFileWithProvider(provider, filePath, account, tier, stat3, resolveGitBranch);
2137
+ return this.indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch);
1994
2138
  }
1995
2139
  const resume = change === "appended" && !force && existing?.reducer_state;
1996
2140
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
@@ -2009,15 +2153,15 @@ var PersistentEngine = class {
2009
2153
  return null;
2010
2154
  }
2011
2155
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2012
- const fp = stat3.size > 0 ? fingerprint(filePath, stat3.size) : null;
2156
+ const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2013
2157
  const fileId = this.files.ensure(filePath, account);
2014
2158
  const upsert = this.db.transaction(() => {
2015
2159
  this.conversations.upsert(fileId, meta, state.pageMessageCount);
2016
2160
  this.fts.upsert(meta);
2017
2161
  this.checkpoints.remove(filePath);
2018
2162
  this.files.updateCursor(fileId, {
2019
- sizeBytes: stat3.size,
2020
- mtimeMs: stat3.mtimeMs,
2163
+ sizeBytes: stat4.size,
2164
+ mtimeMs: stat4.mtimeMs,
2021
2165
  // Advance only to the last fully-parsed line; a trailing partial line
2022
2166
  // is left for the next pass.
2023
2167
  offset: result.newOffset,
@@ -2034,8 +2178,8 @@ var PersistentEngine = class {
2034
2178
  buildSidecar(
2035
2179
  meta,
2036
2180
  {
2037
- sizeBytes: stat3.size,
2038
- mtimeMs: stat3.mtimeMs,
2181
+ sizeBytes: stat4.size,
2182
+ mtimeMs: stat4.mtimeMs,
2039
2183
  offset: result.newOffset,
2040
2184
  line: result.newLine
2041
2185
  },
@@ -2054,7 +2198,7 @@ var PersistentEngine = class {
2054
2198
  // the Threadbase path uses. The cursor records size/mtime (and offset = size)
2055
2199
  // so the next pass classifies an unchanged file as "unchanged" and skips it;
2056
2200
  // any change reparses from 0 again. No reducer_state is persisted.
2057
- async indexFileWithProvider(provider, filePath, account, tier, stat3, resolveGitBranch) {
2201
+ async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
2058
2202
  const log = getLogger();
2059
2203
  const meta = await parseMetaWithProvider(provider, filePath, account, tier);
2060
2204
  if (!meta) {
@@ -2064,18 +2208,18 @@ var PersistentEngine = class {
2064
2208
  if (meta.gitBranch === null && meta.projectPath) {
2065
2209
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2066
2210
  }
2067
- const fp = stat3.size > 0 ? fingerprint(filePath, stat3.size) : null;
2211
+ const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2068
2212
  const fileId = this.files.ensure(filePath, account);
2069
2213
  const upsert = this.db.transaction(() => {
2070
2214
  this.conversations.upsert(fileId, meta, meta.messageCount);
2071
2215
  this.fts.upsert(meta);
2072
2216
  this.checkpoints.remove(filePath);
2073
2217
  this.files.updateCursor(fileId, {
2074
- sizeBytes: stat3.size,
2075
- mtimeMs: stat3.mtimeMs,
2218
+ sizeBytes: stat4.size,
2219
+ mtimeMs: stat4.mtimeMs,
2076
2220
  // offset = size marks the file fully consumed (non-zero so the next pass
2077
2221
  // can classify it "unchanged"); no resumable reducer state is kept.
2078
- offset: stat3.size,
2222
+ offset: stat4.size,
2079
2223
  line: 0,
2080
2224
  reducerState: null,
2081
2225
  fingerprint: fp,