@threadbase-sh/scanner 0.11.0 → 0.11.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.
package/dist/index.js CHANGED
@@ -834,6 +834,14 @@ import { createReadStream as createReadStream2 } from "fs";
834
834
  import { stat } from "fs/promises";
835
835
  import { basename as basename3 } from "path";
836
836
  import { createInterface as createInterface2 } from "readline";
837
+
838
+ // src/canonical-path.ts
839
+ import { sep } from "path";
840
+ function canonicalPath(p) {
841
+ return sep === "\\" ? p.replace(/\//g, "\\") : p;
842
+ }
843
+
844
+ // src/providers/codex-cli.ts
837
845
  var CodexCliProvider = class {
838
846
  name = CODEX_CLI_PROVIDER;
839
847
  async discover(roots) {
@@ -855,7 +863,7 @@ var CodexCliProvider = class {
855
863
  for (const filePath of paths) {
856
864
  try {
857
865
  const s = await stat(filePath);
858
- if (s.size > 0) results.push({ filePath, account: "codex" });
866
+ if (s.size > 0) results.push({ filePath: canonicalPath(filePath), account: "codex" });
859
867
  } catch (err) {
860
868
  log.warn({ filePath, err }, "codex discovery: stat failed");
861
869
  }
@@ -1098,7 +1106,7 @@ async function discoverJsonlFiles(dirs, onProgress) {
1098
1106
  if (size < 0) {
1099
1107
  skippedInaccessible++;
1100
1108
  } else if (size > 0) {
1101
- results.push({ filePath, account });
1109
+ results.push({ filePath: canonicalPath(filePath), account });
1102
1110
  kept++;
1103
1111
  } else {
1104
1112
  skippedEmpty++;
@@ -1163,7 +1171,7 @@ var ThreadbaseProvider = class {
1163
1171
  import { EventEmitter } from "events";
1164
1172
  import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync3 } from "fs";
1165
1173
  import { homedir as homedir2 } from "os";
1166
- import { join as join4 } from "path";
1174
+ import { join as join5 } from "path";
1167
1175
 
1168
1176
  // src/cache.ts
1169
1177
  var LRUCache = class {
@@ -1718,6 +1726,7 @@ function openDatabase(dbPath) {
1718
1726
 
1719
1727
  // src/persistent/dir-watermark.ts
1720
1728
  import { readdir, stat as stat3 } from "fs/promises";
1729
+ import { dirname as dirname4, join as join4 } from "path";
1721
1730
  var FULL_RECONCILE_EVERY_N_SCANS = 20;
1722
1731
  async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
1723
1732
  const log = getLogger();
@@ -1727,7 +1736,7 @@ async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
1727
1736
  const results = [];
1728
1737
  for (const rawDir of dirs) {
1729
1738
  const { account } = rawDir;
1730
- const projectsDir = rawDir.projectsDir.replace(/\\/g, "/");
1739
+ const projectsDir = canonicalPath(rawDir.projectsDir);
1731
1740
  const resolved = await resolveProjectDirs(projectsDir, scannedDirs);
1732
1741
  if (resolved === null) continue;
1733
1742
  for (const projectDir of resolved.entries) {
@@ -1788,11 +1797,10 @@ async function resolveProjectDirs(projectsDir, scannedDirs) {
1788
1797
  return { entries, commitRoot: { mtimeMs: rootStat.mtimeMs } };
1789
1798
  }
1790
1799
  function dirnameOf(filePath) {
1791
- const idx = filePath.lastIndexOf("/");
1792
- return idx === -1 ? filePath : filePath.slice(0, idx);
1800
+ return dirname4(filePath);
1793
1801
  }
1794
1802
  function joinPath(dir, name) {
1795
- return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
1803
+ return join4(dir, name);
1796
1804
  }
1797
1805
 
1798
1806
  // src/persistent/repositories/checkpoints.repo.ts
@@ -1802,15 +1810,16 @@ var CheckpointsRepo = class {
1802
1810
  }
1803
1811
  db;
1804
1812
  replaceAll(sourcePath, checkpoints) {
1813
+ const path = canonicalPath(sourcePath);
1805
1814
  const tx = this.db.transaction(() => {
1806
- this.db.prepare("DELETE FROM message_checkpoints WHERE source_path = ?").run(sourcePath);
1815
+ this.db.prepare("DELETE FROM message_checkpoints WHERE source_path = ?").run(path);
1807
1816
  const insert = this.db.prepare(
1808
1817
  `INSERT INTO message_checkpoints
1809
1818
  (source_path, message_index, byte_offset, line_number, parser_state)
1810
1819
  VALUES (?, ?, ?, ?, ?)`
1811
1820
  );
1812
1821
  for (const c of checkpoints) {
1813
- insert.run(sourcePath, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1822
+ insert.run(path, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1814
1823
  }
1815
1824
  });
1816
1825
  tx();
@@ -1819,6 +1828,7 @@ var CheckpointsRepo = class {
1819
1828
  // the chain covering the immutable prefix (Kafka sparse-index style); rows are
1820
1829
  // only ever removed on truncation/replace or deletion.
1821
1830
  append(sourcePath, checkpoints) {
1831
+ const path = canonicalPath(sourcePath);
1822
1832
  const tx = this.db.transaction(() => {
1823
1833
  const insert = this.db.prepare(
1824
1834
  `INSERT INTO message_checkpoints
@@ -1826,7 +1836,7 @@ var CheckpointsRepo = class {
1826
1836
  VALUES (?, ?, ?, ?, ?)`
1827
1837
  );
1828
1838
  for (const c of checkpoints) {
1829
- insert.run(sourcePath, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1839
+ insert.run(path, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1830
1840
  }
1831
1841
  });
1832
1842
  tx();
@@ -1839,7 +1849,7 @@ var CheckpointsRepo = class {
1839
1849
  FROM message_checkpoints
1840
1850
  WHERE source_path = ? AND message_index <= ?
1841
1851
  ORDER BY message_index DESC LIMIT 1`
1842
- ).get(sourcePath, messageIndex);
1852
+ ).get(canonicalPath(sourcePath), messageIndex);
1843
1853
  return row ? toCheckpoint(row) : null;
1844
1854
  }
1845
1855
  // The highest-index checkpoint for a file, or null if none. The resume point
@@ -1850,14 +1860,14 @@ var CheckpointsRepo = class {
1850
1860
  FROM message_checkpoints
1851
1861
  WHERE source_path = ?
1852
1862
  ORDER BY message_index DESC LIMIT 1`
1853
- ).get(sourcePath);
1863
+ ).get(canonicalPath(sourcePath));
1854
1864
  return row ? toCheckpoint(row) : null;
1855
1865
  }
1856
1866
  count(sourcePath) {
1857
- return this.db.prepare("SELECT COUNT(*) AS n FROM message_checkpoints WHERE source_path = ?").get(sourcePath).n;
1867
+ return this.db.prepare("SELECT COUNT(*) AS n FROM message_checkpoints WHERE source_path = ?").get(canonicalPath(sourcePath)).n;
1858
1868
  }
1859
1869
  remove(sourcePath) {
1860
- this.db.prepare("DELETE FROM message_checkpoints WHERE source_path = ?").run(sourcePath);
1870
+ this.db.prepare("DELETE FROM message_checkpoints WHERE source_path = ?").run(canonicalPath(sourcePath));
1861
1871
  }
1862
1872
  };
1863
1873
  function toCheckpoint(row) {
@@ -1870,24 +1880,30 @@ function toCheckpoint(row) {
1870
1880
  }
1871
1881
 
1872
1882
  // src/persistent/repositories/conversation-files.repo.ts
1873
- import { basename as basename5, dirname as dirname4 } from "path";
1883
+ import { basename as basename5, dirname as dirname5 } from "path";
1874
1884
  var ConversationFilesRepo = class {
1875
1885
  constructor(db) {
1876
1886
  this.db = db;
1877
1887
  }
1878
1888
  db;
1879
1889
  getByPath(absolutePath) {
1880
- return this.db.prepare("SELECT * FROM conversation_files WHERE absolute_path = ?").get(absolutePath);
1890
+ return this.db.prepare("SELECT * FROM conversation_files WHERE absolute_path = ?").get(canonicalPath(absolutePath));
1881
1891
  }
1882
1892
  // Insert a freshly-discovered file at offset 0; returns its row id. Existing
1883
1893
  // path is left untouched (returns the existing id) so a re-discovery is safe.
1894
+ //
1895
+ // absolute_path/parent_dir are stored canonicalized so a caller that built the
1896
+ // path with the native separator (watcher, path.join) and one that got it from
1897
+ // fast-glob (forward slashes, even on Windows) land on the same row instead of
1898
+ // inserting a duplicate.
1884
1899
  ensure(absolutePath, account) {
1885
- const existing = this.getByPath(absolutePath);
1900
+ const canonical = canonicalPath(absolutePath);
1901
+ const existing = this.getByPath(canonical);
1886
1902
  if (existing) return existing.id;
1887
1903
  const info = this.db.prepare(
1888
1904
  `INSERT INTO conversation_files (absolute_path, parent_dir, file_name, account)
1889
1905
  VALUES (?, ?, ?, ?)`
1890
- ).run(absolutePath, dirname4(absolutePath), basename5(absolutePath), account);
1906
+ ).run(canonical, dirname5(canonical), basename5(canonical), account);
1891
1907
  return Number(info.lastInsertRowid);
1892
1908
  }
1893
1909
  // Advance the cursor + persisted reducer state after a successful index pass.
@@ -1949,7 +1965,7 @@ var ConversationFilesRepo = class {
1949
1965
  activePathsByParentDir(parentDir) {
1950
1966
  return this.db.prepare(
1951
1967
  "SELECT absolute_path, account FROM conversation_files WHERE parent_dir = ? AND status != 'deleted'"
1952
- ).all(parentDir);
1968
+ ).all(canonicalPath(parentDir));
1953
1969
  }
1954
1970
  };
1955
1971
 
@@ -2044,7 +2060,10 @@ var ConversationsRepo = class {
2044
2060
  updated_at = CURRENT_TIMESTAMP`
2045
2061
  ).run({
2046
2062
  file_id: fileId,
2047
- source_path: meta.id,
2063
+ // Canonical form, matching conversation_files.absolute_path — otherwise
2064
+ // a native-separator caller writes a second spelling of the same file
2065
+ // that every by-path lookup (and the deletion-reconcile) then misses.
2066
+ source_path: canonicalPath(meta.id),
2048
2067
  provider: meta.provider ?? CLAUDE_CODE_PROVIDER,
2049
2068
  kind: meta.kind ?? null,
2050
2069
  external_session_id: meta.externalSessionId ?? null,
@@ -2074,7 +2093,7 @@ var ConversationsRepo = class {
2074
2093
  });
2075
2094
  }
2076
2095
  getBySourcePath(sourcePath) {
2077
- const row = this.db.prepare("SELECT * FROM conversations WHERE source_path = ? AND status = 'active'").get(sourcePath);
2096
+ const row = this.db.prepare("SELECT * FROM conversations WHERE source_path = ? AND status = 'active'").get(canonicalPath(sourcePath));
2078
2097
  return row ? rowToMeta(row) : null;
2079
2098
  }
2080
2099
  // Dual lookup matching scanner.getConversation: resolve by source_path (the
@@ -2138,7 +2157,7 @@ var ConversationsRepo = class {
2138
2157
  pageMessageCount(sourcePath) {
2139
2158
  const row = this.db.prepare(
2140
2159
  "SELECT page_message_count AS n FROM conversations WHERE source_path = ? AND status = 'active'"
2141
- ).get(sourcePath);
2160
+ ).get(canonicalPath(sourcePath));
2142
2161
  return row?.n ?? 0;
2143
2162
  }
2144
2163
  count() {
@@ -2153,14 +2172,15 @@ var FtsRepo = class {
2153
2172
  }
2154
2173
  db;
2155
2174
  upsert(meta) {
2175
+ const sourcePath = canonicalPath(meta.id);
2156
2176
  const tx = this.db.transaction(() => {
2157
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(meta.id);
2177
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(sourcePath);
2158
2178
  this.db.prepare(
2159
2179
  `INSERT INTO conversation_messages_fts
2160
2180
  (source_path, content, project_name, session_id, session_name, account, model, branch, tool_names)
2161
2181
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
2162
2182
  ).run(
2163
- meta.id,
2183
+ sourcePath,
2164
2184
  meta.contentSnippet ?? "",
2165
2185
  meta.projectName ?? "",
2166
2186
  meta.sessionId ?? "",
@@ -2174,7 +2194,7 @@ var FtsRepo = class {
2174
2194
  tx();
2175
2195
  }
2176
2196
  remove(sourcePath) {
2177
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(sourcePath);
2197
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(canonicalPath(sourcePath));
2178
2198
  }
2179
2199
  // Ranked source_paths matching the query, best first. Returns [] on an empty
2180
2200
  // query (callers fall back to a recency listing).
@@ -2206,11 +2226,11 @@ var ScannedDirsRepo = class {
2206
2226
  }
2207
2227
  db;
2208
2228
  get(path) {
2209
- return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(path);
2229
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(canonicalPath(path));
2210
2230
  }
2211
2231
  // Known project subdirectories under a root, path ascending (stable order).
2212
2232
  childrenOf(parentRoot) {
2213
- return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(parentRoot);
2233
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(canonicalPath(parentRoot));
2214
2234
  }
2215
2235
  upsert(path, parentRoot, mtimeMs, hasNested) {
2216
2236
  this.db.prepare(
@@ -2221,11 +2241,16 @@ var ScannedDirsRepo = class {
2221
2241
  mtime_ms = excluded.mtime_ms,
2222
2242
  has_nested = excluded.has_nested,
2223
2243
  updated_at = CURRENT_TIMESTAMP`
2224
- ).run(path, parentRoot, mtimeMs, hasNested ? 1 : 0);
2244
+ ).run(
2245
+ canonicalPath(path),
2246
+ parentRoot === null ? null : canonicalPath(parentRoot),
2247
+ mtimeMs,
2248
+ hasNested ? 1 : 0
2249
+ );
2225
2250
  }
2226
2251
  // Drop a project subdirectory's watermark row (it vanished from disk).
2227
2252
  remove(path) {
2228
- this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(path);
2253
+ this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(canonicalPath(path));
2229
2254
  }
2230
2255
  };
2231
2256
 
@@ -2325,7 +2350,7 @@ var PersistentEngine = class {
2325
2350
  if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
2326
2351
  coveredAccounts.add("codex");
2327
2352
  }
2328
- const seen = new Set(discovered.map((d) => d.filePath));
2353
+ const seen = new Set(discovered.map((d) => canonicalPath(d.filePath)));
2329
2354
  for (const path of this.files.activePathsByAccounts([...coveredAccounts])) {
2330
2355
  if (!seen.has(path)) this.markDeleted(path);
2331
2356
  }
@@ -2339,9 +2364,10 @@ var PersistentEngine = class {
2339
2364
  // half-written row or an over-advanced cursor. Returns the classification
2340
2365
  // alongside the meta so callers (refreshFile) can keep, extend, or evict
2341
2366
  // their own per-file caches without re-stat'ing the file (racy) themselves.
2342
- async indexFile(filePath, account, tierName, customTiers, resolveGitBranch, force = false, provider) {
2367
+ async indexFile(rawFilePath, account, tierName, customTiers, resolveGitBranch, force = false, provider) {
2343
2368
  const log = getLogger();
2344
2369
  const tier = resolveTier(tierName, customTiers);
2370
+ const filePath = canonicalPath(rawFilePath);
2345
2371
  const existing = this.files.getByPath(filePath);
2346
2372
  const { change, stat: stat4 } = classify(filePath, existing);
2347
2373
  if (change === "vanished" || !stat4) {
@@ -2652,7 +2678,7 @@ var IndexQueue = class {
2652
2678
  var BATCH_SIZE2 = 12;
2653
2679
  var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
2654
2680
  function defaultDbPath() {
2655
- return process.env.TB_SCANNER_DB ?? join4(homedir2(), ".config", "threadbase-scanner", "index.db");
2681
+ return process.env.TB_SCANNER_DB ?? join5(homedir2(), ".config", "threadbase-scanner", "index.db");
2656
2682
  }
2657
2683
  var ConversationScanner = class {
2658
2684
  metadataCache = /* @__PURE__ */ new Map();
@@ -2945,12 +2971,13 @@ var ConversationScanner = class {
2945
2971
  }
2946
2972
  async getConversation(id, _options) {
2947
2973
  const log = getLogger();
2948
- const cached = this.conversationLRU.get(id);
2974
+ const cid = canonicalPath(id);
2975
+ const cached = this.conversationLRU.get(cid);
2949
2976
  if (cached) {
2950
2977
  log.debug({ id }, "getConversation: cache hit");
2951
2978
  return cached.conversation;
2952
2979
  }
2953
- const meta = this.persistent ? this.engine().getByIdOrSession(id) : this.metadataCache.get(id) ?? this.resolveSessionId(id);
2980
+ const meta = this.persistent ? this.engine().getByIdOrSession(cid) : this.metadataCache.get(cid) ?? this.resolveSessionId(cid);
2954
2981
  if (!meta) {
2955
2982
  log.debug({ id }, "getConversation: not found in metadata");
2956
2983
  return null;
@@ -2959,12 +2986,12 @@ var ConversationScanner = class {
2959
2986
  try {
2960
2987
  if (this.persistent && meta.provider !== CODEX_CLI_PROVIDER) {
2961
2988
  const parsed = await parseConversationResumable(meta.filePath, meta.account);
2962
- if (parsed) this.conversationLRU.set(id, parsed);
2989
+ if (parsed) this.conversationLRU.set(cid, parsed);
2963
2990
  return parsed?.conversation ?? null;
2964
2991
  }
2965
2992
  const conversation = meta.provider === CODEX_CLI_PROVIDER ? await parseCodexConversation(meta.filePath, meta.account) : await parseConversation(meta.filePath, meta.account);
2966
2993
  if (conversation) {
2967
- this.conversationLRU.set(id, { conversation });
2994
+ this.conversationLRU.set(cid, { conversation });
2968
2995
  }
2969
2996
  return conversation;
2970
2997
  } catch (err) {
@@ -2992,7 +3019,7 @@ var ConversationScanner = class {
2992
3019
  // which is identical by construction.
2993
3020
  async getConversationPage(id, options) {
2994
3021
  if (this.persistent) {
2995
- return this.engine().getPage(id, options);
3022
+ return this.engine().getPage(canonicalPath(id), options);
2996
3023
  }
2997
3024
  const conversation = await this.getConversation(id);
2998
3025
  if (!conversation) return null;
@@ -3042,14 +3069,15 @@ var ConversationScanner = class {
3042
3069
  // each re-reading the file.
3043
3070
  refreshesInFlight = /* @__PURE__ */ new Map();
3044
3071
  refreshFile(filePath, account) {
3045
- const inFlight = this.refreshesInFlight.get(filePath);
3072
+ const cpath = canonicalPath(filePath);
3073
+ const inFlight = this.refreshesInFlight.get(cpath);
3046
3074
  if (inFlight) return inFlight;
3047
- const refresh = this.doRefreshFile(filePath, account).finally(() => {
3048
- if (this.refreshesInFlight.get(filePath) === refresh) {
3049
- this.refreshesInFlight.delete(filePath);
3075
+ const refresh = this.doRefreshFile(cpath, account).finally(() => {
3076
+ if (this.refreshesInFlight.get(cpath) === refresh) {
3077
+ this.refreshesInFlight.delete(cpath);
3050
3078
  }
3051
3079
  });
3052
- this.refreshesInFlight.set(filePath, refresh);
3080
+ this.refreshesInFlight.set(cpath, refresh);
3053
3081
  return refresh;
3054
3082
  }
3055
3083
  async doRefreshFile(filePath, account) {