@threadbase-sh/scanner 0.10.2 → 0.11.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.10.2";
8
+ var version = "0.11.1";
9
9
 
10
10
  // src/logger.ts
11
11
  import pino from "pino";
@@ -62,7 +62,7 @@ async function saveProfiles(profiles, configPath) {
62
62
  import { EventEmitter } from "events";
63
63
  import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync3 } from "fs";
64
64
  import { homedir as homedir2 } from "os";
65
- import { join as join4 } from "path";
65
+ import { join as join5 } from "path";
66
66
 
67
67
  // src/cache.ts
68
68
  var LRUCache = class {
@@ -100,6 +100,12 @@ var LRUCache = class {
100
100
  }
101
101
  };
102
102
 
103
+ // src/canonical-path.ts
104
+ import { sep } from "path";
105
+ function canonicalPath(p) {
106
+ return sep === "\\" ? p.replace(/\//g, "\\") : p;
107
+ }
108
+
103
109
  // src/discovery.ts
104
110
  import fg from "fast-glob";
105
111
  import { stat } from "fs/promises";
@@ -141,7 +147,7 @@ async function discoverJsonlFiles(dirs, onProgress) {
141
147
  if (size < 0) {
142
148
  skippedInaccessible++;
143
149
  } else if (size > 0) {
144
- results.push({ filePath, account });
150
+ results.push({ filePath: canonicalPath(filePath), account });
145
151
  kept++;
146
152
  } else {
147
153
  skippedEmpty++;
@@ -1173,7 +1179,7 @@ var CodexCliProvider = class {
1173
1179
  for (const filePath of paths) {
1174
1180
  try {
1175
1181
  const s = await stat2(filePath);
1176
- if (s.size > 0) results.push({ filePath, account: "codex" });
1182
+ if (s.size > 0) results.push({ filePath: canonicalPath(filePath), account: "codex" });
1177
1183
  } catch (err) {
1178
1184
  log.warn({ filePath, err }, "codex discovery: stat failed");
1179
1185
  }
@@ -1632,6 +1638,7 @@ function openDatabase(dbPath) {
1632
1638
 
1633
1639
  // src/persistent/dir-watermark.ts
1634
1640
  import { readdir, stat as stat3 } from "fs/promises";
1641
+ import { dirname as dirname4, join as join4 } from "path";
1635
1642
  var FULL_RECONCILE_EVERY_N_SCANS = 20;
1636
1643
  async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
1637
1644
  const log = getLogger();
@@ -1641,7 +1648,7 @@ async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
1641
1648
  const results = [];
1642
1649
  for (const rawDir of dirs) {
1643
1650
  const { account } = rawDir;
1644
- const projectsDir = rawDir.projectsDir.replace(/\\/g, "/");
1651
+ const projectsDir = canonicalPath(rawDir.projectsDir);
1645
1652
  const resolved = await resolveProjectDirs(projectsDir, scannedDirs);
1646
1653
  if (resolved === null) continue;
1647
1654
  for (const projectDir of resolved.entries) {
@@ -1702,11 +1709,10 @@ async function resolveProjectDirs(projectsDir, scannedDirs) {
1702
1709
  return { entries, commitRoot: { mtimeMs: rootStat.mtimeMs } };
1703
1710
  }
1704
1711
  function dirnameOf(filePath) {
1705
- const idx = filePath.lastIndexOf("/");
1706
- return idx === -1 ? filePath : filePath.slice(0, idx);
1712
+ return dirname4(filePath);
1707
1713
  }
1708
1714
  function joinPath(dir, name) {
1709
- return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
1715
+ return join4(dir, name);
1710
1716
  }
1711
1717
 
1712
1718
  // src/persistent/repositories/checkpoints.repo.ts
@@ -1716,15 +1722,16 @@ var CheckpointsRepo = class {
1716
1722
  }
1717
1723
  db;
1718
1724
  replaceAll(sourcePath, checkpoints) {
1725
+ const path = canonicalPath(sourcePath);
1719
1726
  const tx = this.db.transaction(() => {
1720
- this.db.prepare("DELETE FROM message_checkpoints WHERE source_path = ?").run(sourcePath);
1727
+ this.db.prepare("DELETE FROM message_checkpoints WHERE source_path = ?").run(path);
1721
1728
  const insert = this.db.prepare(
1722
1729
  `INSERT INTO message_checkpoints
1723
1730
  (source_path, message_index, byte_offset, line_number, parser_state)
1724
1731
  VALUES (?, ?, ?, ?, ?)`
1725
1732
  );
1726
1733
  for (const c of checkpoints) {
1727
- insert.run(sourcePath, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1734
+ insert.run(path, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1728
1735
  }
1729
1736
  });
1730
1737
  tx();
@@ -1733,6 +1740,7 @@ var CheckpointsRepo = class {
1733
1740
  // the chain covering the immutable prefix (Kafka sparse-index style); rows are
1734
1741
  // only ever removed on truncation/replace or deletion.
1735
1742
  append(sourcePath, checkpoints) {
1743
+ const path = canonicalPath(sourcePath);
1736
1744
  const tx = this.db.transaction(() => {
1737
1745
  const insert = this.db.prepare(
1738
1746
  `INSERT INTO message_checkpoints
@@ -1740,7 +1748,7 @@ var CheckpointsRepo = class {
1740
1748
  VALUES (?, ?, ?, ?, ?)`
1741
1749
  );
1742
1750
  for (const c of checkpoints) {
1743
- insert.run(sourcePath, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1751
+ insert.run(path, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1744
1752
  }
1745
1753
  });
1746
1754
  tx();
@@ -1753,7 +1761,7 @@ var CheckpointsRepo = class {
1753
1761
  FROM message_checkpoints
1754
1762
  WHERE source_path = ? AND message_index <= ?
1755
1763
  ORDER BY message_index DESC LIMIT 1`
1756
- ).get(sourcePath, messageIndex);
1764
+ ).get(canonicalPath(sourcePath), messageIndex);
1757
1765
  return row ? toCheckpoint(row) : null;
1758
1766
  }
1759
1767
  // The highest-index checkpoint for a file, or null if none. The resume point
@@ -1764,14 +1772,14 @@ var CheckpointsRepo = class {
1764
1772
  FROM message_checkpoints
1765
1773
  WHERE source_path = ?
1766
1774
  ORDER BY message_index DESC LIMIT 1`
1767
- ).get(sourcePath);
1775
+ ).get(canonicalPath(sourcePath));
1768
1776
  return row ? toCheckpoint(row) : null;
1769
1777
  }
1770
1778
  count(sourcePath) {
1771
- return this.db.prepare("SELECT COUNT(*) AS n FROM message_checkpoints WHERE source_path = ?").get(sourcePath).n;
1779
+ return this.db.prepare("SELECT COUNT(*) AS n FROM message_checkpoints WHERE source_path = ?").get(canonicalPath(sourcePath)).n;
1772
1780
  }
1773
1781
  remove(sourcePath) {
1774
- this.db.prepare("DELETE FROM message_checkpoints WHERE source_path = ?").run(sourcePath);
1782
+ this.db.prepare("DELETE FROM message_checkpoints WHERE source_path = ?").run(canonicalPath(sourcePath));
1775
1783
  }
1776
1784
  };
1777
1785
  function toCheckpoint(row) {
@@ -1784,24 +1792,30 @@ function toCheckpoint(row) {
1784
1792
  }
1785
1793
 
1786
1794
  // src/persistent/repositories/conversation-files.repo.ts
1787
- import { basename as basename5, dirname as dirname4 } from "path";
1795
+ import { basename as basename5, dirname as dirname5 } from "path";
1788
1796
  var ConversationFilesRepo = class {
1789
1797
  constructor(db) {
1790
1798
  this.db = db;
1791
1799
  }
1792
1800
  db;
1793
1801
  getByPath(absolutePath) {
1794
- return this.db.prepare("SELECT * FROM conversation_files WHERE absolute_path = ?").get(absolutePath);
1802
+ return this.db.prepare("SELECT * FROM conversation_files WHERE absolute_path = ?").get(canonicalPath(absolutePath));
1795
1803
  }
1796
1804
  // Insert a freshly-discovered file at offset 0; returns its row id. Existing
1797
1805
  // path is left untouched (returns the existing id) so a re-discovery is safe.
1806
+ //
1807
+ // absolute_path/parent_dir are stored canonicalized so a caller that built the
1808
+ // path with the native separator (watcher, path.join) and one that got it from
1809
+ // fast-glob (forward slashes, even on Windows) land on the same row instead of
1810
+ // inserting a duplicate.
1798
1811
  ensure(absolutePath, account) {
1799
- const existing = this.getByPath(absolutePath);
1812
+ const canonical = canonicalPath(absolutePath);
1813
+ const existing = this.getByPath(canonical);
1800
1814
  if (existing) return existing.id;
1801
1815
  const info = this.db.prepare(
1802
1816
  `INSERT INTO conversation_files (absolute_path, parent_dir, file_name, account)
1803
1817
  VALUES (?, ?, ?, ?)`
1804
- ).run(absolutePath, dirname4(absolutePath), basename5(absolutePath), account);
1818
+ ).run(canonical, dirname5(canonical), basename5(canonical), account);
1805
1819
  return Number(info.lastInsertRowid);
1806
1820
  }
1807
1821
  // Advance the cursor + persisted reducer state after a successful index pass.
@@ -1863,7 +1877,7 @@ var ConversationFilesRepo = class {
1863
1877
  activePathsByParentDir(parentDir) {
1864
1878
  return this.db.prepare(
1865
1879
  "SELECT absolute_path, account FROM conversation_files WHERE parent_dir = ? AND status != 'deleted'"
1866
- ).all(parentDir);
1880
+ ).all(canonicalPath(parentDir));
1867
1881
  }
1868
1882
  };
1869
1883
 
@@ -1958,7 +1972,10 @@ var ConversationsRepo = class {
1958
1972
  updated_at = CURRENT_TIMESTAMP`
1959
1973
  ).run({
1960
1974
  file_id: fileId,
1961
- source_path: meta.id,
1975
+ // Canonical form, matching conversation_files.absolute_path — otherwise
1976
+ // a native-separator caller writes a second spelling of the same file
1977
+ // that every by-path lookup (and the deletion-reconcile) then misses.
1978
+ source_path: canonicalPath(meta.id),
1962
1979
  provider: meta.provider ?? CLAUDE_CODE_PROVIDER,
1963
1980
  kind: meta.kind ?? null,
1964
1981
  external_session_id: meta.externalSessionId ?? null,
@@ -1988,7 +2005,7 @@ var ConversationsRepo = class {
1988
2005
  });
1989
2006
  }
1990
2007
  getBySourcePath(sourcePath) {
1991
- const row = this.db.prepare("SELECT * FROM conversations WHERE source_path = ? AND status = 'active'").get(sourcePath);
2008
+ const row = this.db.prepare("SELECT * FROM conversations WHERE source_path = ? AND status = 'active'").get(canonicalPath(sourcePath));
1992
2009
  return row ? rowToMeta(row) : null;
1993
2010
  }
1994
2011
  // Dual lookup matching scanner.getConversation: resolve by source_path (the
@@ -2052,7 +2069,7 @@ var ConversationsRepo = class {
2052
2069
  pageMessageCount(sourcePath) {
2053
2070
  const row = this.db.prepare(
2054
2071
  "SELECT page_message_count AS n FROM conversations WHERE source_path = ? AND status = 'active'"
2055
- ).get(sourcePath);
2072
+ ).get(canonicalPath(sourcePath));
2056
2073
  return row?.n ?? 0;
2057
2074
  }
2058
2075
  count() {
@@ -2067,14 +2084,15 @@ var FtsRepo = class {
2067
2084
  }
2068
2085
  db;
2069
2086
  upsert(meta) {
2087
+ const sourcePath = canonicalPath(meta.id);
2070
2088
  const tx = this.db.transaction(() => {
2071
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(meta.id);
2089
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(sourcePath);
2072
2090
  this.db.prepare(
2073
2091
  `INSERT INTO conversation_messages_fts
2074
2092
  (source_path, content, project_name, session_id, session_name, account, model, branch, tool_names)
2075
2093
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
2076
2094
  ).run(
2077
- meta.id,
2095
+ sourcePath,
2078
2096
  meta.contentSnippet ?? "",
2079
2097
  meta.projectName ?? "",
2080
2098
  meta.sessionId ?? "",
@@ -2088,7 +2106,7 @@ var FtsRepo = class {
2088
2106
  tx();
2089
2107
  }
2090
2108
  remove(sourcePath) {
2091
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(sourcePath);
2109
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(canonicalPath(sourcePath));
2092
2110
  }
2093
2111
  // Ranked source_paths matching the query, best first. Returns [] on an empty
2094
2112
  // query (callers fall back to a recency listing).
@@ -2120,11 +2138,11 @@ var ScannedDirsRepo = class {
2120
2138
  }
2121
2139
  db;
2122
2140
  get(path) {
2123
- return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(path);
2141
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(canonicalPath(path));
2124
2142
  }
2125
2143
  // Known project subdirectories under a root, path ascending (stable order).
2126
2144
  childrenOf(parentRoot) {
2127
- return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(parentRoot);
2145
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(canonicalPath(parentRoot));
2128
2146
  }
2129
2147
  upsert(path, parentRoot, mtimeMs, hasNested) {
2130
2148
  this.db.prepare(
@@ -2135,11 +2153,16 @@ var ScannedDirsRepo = class {
2135
2153
  mtime_ms = excluded.mtime_ms,
2136
2154
  has_nested = excluded.has_nested,
2137
2155
  updated_at = CURRENT_TIMESTAMP`
2138
- ).run(path, parentRoot, mtimeMs, hasNested ? 1 : 0);
2156
+ ).run(
2157
+ canonicalPath(path),
2158
+ parentRoot === null ? null : canonicalPath(parentRoot),
2159
+ mtimeMs,
2160
+ hasNested ? 1 : 0
2161
+ );
2139
2162
  }
2140
2163
  // Drop a project subdirectory's watermark row (it vanished from disk).
2141
2164
  remove(path) {
2142
- this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(path);
2165
+ this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(canonicalPath(path));
2143
2166
  }
2144
2167
  };
2145
2168
 
@@ -2272,7 +2295,7 @@ var PersistentEngine = class {
2272
2295
  if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
2273
2296
  coveredAccounts.add("codex");
2274
2297
  }
2275
- const seen = new Set(discovered.map((d) => d.filePath));
2298
+ const seen = new Set(discovered.map((d) => canonicalPath(d.filePath)));
2276
2299
  for (const path of this.files.activePathsByAccounts([...coveredAccounts])) {
2277
2300
  if (!seen.has(path)) this.markDeleted(path);
2278
2301
  }
@@ -2286,9 +2309,10 @@ var PersistentEngine = class {
2286
2309
  // half-written row or an over-advanced cursor. Returns the classification
2287
2310
  // alongside the meta so callers (refreshFile) can keep, extend, or evict
2288
2311
  // their own per-file caches without re-stat'ing the file (racy) themselves.
2289
- async indexFile(filePath, account, tierName, customTiers, resolveGitBranch, force = false, provider) {
2312
+ async indexFile(rawFilePath, account, tierName, customTiers, resolveGitBranch, force = false, provider) {
2290
2313
  const log = getLogger();
2291
2314
  const tier = resolveTier(tierName, customTiers);
2315
+ const filePath = canonicalPath(rawFilePath);
2292
2316
  const existing = this.files.getByPath(filePath);
2293
2317
  const { change, stat: stat4 } = classify(filePath, existing);
2294
2318
  if (change === "vanished" || !stat4) {
@@ -2635,7 +2659,7 @@ var IndexQueue = class {
2635
2659
  var BATCH_SIZE2 = 12;
2636
2660
  var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
2637
2661
  function defaultDbPath() {
2638
- return process.env.TB_SCANNER_DB ?? join4(homedir2(), ".config", "threadbase-scanner", "index.db");
2662
+ return process.env.TB_SCANNER_DB ?? join5(homedir2(), ".config", "threadbase-scanner", "index.db");
2639
2663
  }
2640
2664
  var ConversationScanner = class {
2641
2665
  metadataCache = /* @__PURE__ */ new Map();
@@ -2928,12 +2952,13 @@ var ConversationScanner = class {
2928
2952
  }
2929
2953
  async getConversation(id, _options) {
2930
2954
  const log = getLogger();
2931
- const cached = this.conversationLRU.get(id);
2955
+ const cid = canonicalPath(id);
2956
+ const cached = this.conversationLRU.get(cid);
2932
2957
  if (cached) {
2933
2958
  log.debug({ id }, "getConversation: cache hit");
2934
2959
  return cached.conversation;
2935
2960
  }
2936
- const meta = this.persistent ? this.engine().getByIdOrSession(id) : this.metadataCache.get(id) ?? this.resolveSessionId(id);
2961
+ const meta = this.persistent ? this.engine().getByIdOrSession(cid) : this.metadataCache.get(cid) ?? this.resolveSessionId(cid);
2937
2962
  if (!meta) {
2938
2963
  log.debug({ id }, "getConversation: not found in metadata");
2939
2964
  return null;
@@ -2942,12 +2967,12 @@ var ConversationScanner = class {
2942
2967
  try {
2943
2968
  if (this.persistent && meta.provider !== CODEX_CLI_PROVIDER) {
2944
2969
  const parsed = await parseConversationResumable(meta.filePath, meta.account);
2945
- if (parsed) this.conversationLRU.set(id, parsed);
2970
+ if (parsed) this.conversationLRU.set(cid, parsed);
2946
2971
  return parsed?.conversation ?? null;
2947
2972
  }
2948
2973
  const conversation = meta.provider === CODEX_CLI_PROVIDER ? await parseCodexConversation(meta.filePath, meta.account) : await parseConversation(meta.filePath, meta.account);
2949
2974
  if (conversation) {
2950
- this.conversationLRU.set(id, { conversation });
2975
+ this.conversationLRU.set(cid, { conversation });
2951
2976
  }
2952
2977
  return conversation;
2953
2978
  } catch (err) {
@@ -2975,7 +3000,7 @@ var ConversationScanner = class {
2975
3000
  // which is identical by construction.
2976
3001
  async getConversationPage(id, options) {
2977
3002
  if (this.persistent) {
2978
- return this.engine().getPage(id, options);
3003
+ return this.engine().getPage(canonicalPath(id), options);
2979
3004
  }
2980
3005
  const conversation = await this.getConversation(id);
2981
3006
  if (!conversation) return null;
@@ -3025,14 +3050,15 @@ var ConversationScanner = class {
3025
3050
  // each re-reading the file.
3026
3051
  refreshesInFlight = /* @__PURE__ */ new Map();
3027
3052
  refreshFile(filePath, account) {
3028
- const inFlight = this.refreshesInFlight.get(filePath);
3053
+ const cpath = canonicalPath(filePath);
3054
+ const inFlight = this.refreshesInFlight.get(cpath);
3029
3055
  if (inFlight) return inFlight;
3030
- const refresh = this.doRefreshFile(filePath, account).finally(() => {
3031
- if (this.refreshesInFlight.get(filePath) === refresh) {
3032
- this.refreshesInFlight.delete(filePath);
3056
+ const refresh = this.doRefreshFile(cpath, account).finally(() => {
3057
+ if (this.refreshesInFlight.get(cpath) === refresh) {
3058
+ this.refreshesInFlight.delete(cpath);
3033
3059
  }
3034
3060
  });
3035
- this.refreshesInFlight.set(filePath, refresh);
3061
+ this.refreshesInFlight.set(cpath, refresh);
3036
3062
  return refresh;
3037
3063
  }
3038
3064
  async doRefreshFile(filePath, account) {