@threadbase-sh/streamer 1.39.0 → 1.39.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
@@ -1582,6 +1582,7 @@ declare class StreamerServer {
1582
1582
  private allScanners;
1583
1583
  private scannerReady;
1584
1584
  private scannerStale;
1585
+ private staleFiles;
1585
1586
  private conversationReconcileInFlight;
1586
1587
  private refreshInFlight;
1587
1588
  private binding;
@@ -1821,7 +1822,8 @@ declare class StreamerServer {
1821
1822
  */
1822
1823
  private reconcileConversationsCacheFromDisk;
1823
1824
  private startBackgroundConversationReconcile;
1824
- private shouldAutoReconcileConversationList;
1825
+ private conversationReconcileMode;
1826
+ private reconcileStaleFilesFromDisk;
1825
1827
  private handleListConversations;
1826
1828
  private handleConversationsCount;
1827
1829
  private refreshCountInBackground;
@@ -1831,6 +1833,8 @@ declare class StreamerServer {
1831
1833
  private buildStatCache;
1832
1834
  private codexScanOpts;
1833
1835
  private newScanner;
1836
+ private takeStaleFiles;
1837
+ private refreshStaleFiles;
1834
1838
  private getScanner;
1835
1839
  private getFreshScanner;
1836
1840
  private rescanForRefresh;
package/dist/index.d.ts CHANGED
@@ -1582,6 +1582,7 @@ declare class StreamerServer {
1582
1582
  private allScanners;
1583
1583
  private scannerReady;
1584
1584
  private scannerStale;
1585
+ private staleFiles;
1585
1586
  private conversationReconcileInFlight;
1586
1587
  private refreshInFlight;
1587
1588
  private binding;
@@ -1821,7 +1822,8 @@ declare class StreamerServer {
1821
1822
  */
1822
1823
  private reconcileConversationsCacheFromDisk;
1823
1824
  private startBackgroundConversationReconcile;
1824
- private shouldAutoReconcileConversationList;
1825
+ private conversationReconcileMode;
1826
+ private reconcileStaleFilesFromDisk;
1825
1827
  private handleListConversations;
1826
1828
  private handleConversationsCount;
1827
1829
  private refreshCountInBackground;
@@ -1831,6 +1833,8 @@ declare class StreamerServer {
1831
1833
  private buildStatCache;
1832
1834
  private codexScanOpts;
1833
1835
  private newScanner;
1836
+ private takeStaleFiles;
1837
+ private refreshStaleFiles;
1834
1838
  private getScanner;
1835
1839
  private getFreshScanner;
1836
1840
  private rescanForRefresh;
package/dist/index.js CHANGED
@@ -8830,6 +8830,20 @@ var StreamerServer = class {
8830
8830
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
8831
8831
  // a single rescan after the current one completes instead of restarting it.
8832
8832
  scannerStale = false;
8833
+ // WHICH files scannerStale is about. A directory event names exactly one
8834
+ // JSONL, and the only correct response is refreshFile() on that one file —
8835
+ // but scannerStale alone carries no identity, so honoring it used to mean a
8836
+ // full-tree rescan. On the non-persistent scanner this server actually runs
8837
+ // (buildStatCache => persistent:false, see listen()), scan() opens by
8838
+ // clearing metadataCache AND conversationLRU — so one live session appending
8839
+ // to its own transcript threw away every OTHER conversation's parsed
8840
+ // snapshot, and the next full fetch of an unrelated conversation re-parsed
8841
+ // it from disk (745-2877ms on a 5MB/1112-message history) while the
8842
+ // per-file paginated path stayed at ~20ms throughout. Populated alongside
8843
+ // scannerStale and drained with it by takeStaleFiles(); an armed flag with
8844
+ // an EMPTY set means "stale, source unknown" and still falls back to the
8845
+ // full rescan.
8846
+ staleFiles = /* @__PURE__ */ new Set();
8833
8847
  // Single-flight guard for the background disk reconcile: a burst of list
8834
8848
  // polls during active session writes shares one rescan instead of queueing
8835
8849
  // a full rescan per request.
@@ -8998,7 +9012,10 @@ var StreamerServer = class {
8998
9012
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
8999
9013
  this.markScannerStaleDebounced = debounce(() => {
9000
9014
  if (this.scannerReady) this.scannerStale = true;
9001
- else this.scanner = null;
9015
+ else {
9016
+ this.scanner = null;
9017
+ this.staleFiles.clear();
9018
+ }
9002
9019
  }, this.directoryDebounceMs);
9003
9020
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
9004
9021
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
@@ -9091,6 +9108,7 @@ var StreamerServer = class {
9091
9108
  if (!tailed) this.maybeAttachExternalTail(filePath);
9092
9109
  this.sweepIdleExternalTails();
9093
9110
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
9111
+ this.staleFiles.add(filePath);
9094
9112
  this.markScannerStaleDebounced();
9095
9113
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
9096
9114
  filePath,
@@ -10337,21 +10355,46 @@ var StreamerServer = class {
10337
10355
  // so a burst of list polls during active session writes shares one rescan
10338
10356
  // rather than queueing a full rescan each; tracked so close() awaits the
10339
10357
  // in-flight cache write before shutting the DB.
10340
- startBackgroundConversationReconcile() {
10358
+ startBackgroundConversationReconcile(mode = "full") {
10341
10359
  if (this.conversationReconcileInFlight) return;
10342
- const task = this.reconcileConversationsCacheFromDisk().finally(() => {
10360
+ const paths = mode === "files" ? this.takeStaleFiles() : [];
10361
+ const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileConversationsCacheFromDisk()).finally(() => {
10343
10362
  this.conversationReconcileInFlight = null;
10344
10363
  });
10345
10364
  this.conversationReconcileInFlight = task;
10346
10365
  this.trackCacheWrite(task);
10347
10366
  }
10348
- shouldAutoReconcileConversationList() {
10349
- if (!this.cache) return false;
10350
- if (this.scannerStale) return true;
10351
- if (!this.conversationsRepo || !this.cacheMetadataRepo) return false;
10367
+ // "files": a directory event named specific JSONLs, so refresh only those.
10368
+ // "full": disk drifted in ways a per-file refresh can't see (a project dir
10369
+ // appeared, rows vanished), so walk the tree. Order matters — the staleness
10370
+ // check short-circuits first so the HDD freshness probe stays off the hot
10371
+ // poll path, exactly as it did when this returned a boolean.
10372
+ conversationReconcileMode() {
10373
+ if (!this.cache) return null;
10374
+ if (this.scannerStale) return "files";
10375
+ if (!this.conversationsRepo || !this.cacheMetadataRepo) return null;
10352
10376
  return shouldRefreshProjectsFromHdd(this.conversationsRepo, this.cacheMetadataRepo, {
10353
10377
  projectsDirs: this.projectsDirsForFreshnessCheck()
10354
- });
10378
+ }) ? "full" : null;
10379
+ }
10380
+ // The per-file half of reconcileConversationsCacheFromDisk: re-index just the
10381
+ // changed JSONLs and upsert their rows. No reconcileDeletions here — that
10382
+ // needs the whole live-path set, and deletions already have their own path
10383
+ // (onFileDeleted -> invalidateByFilePath). New projects still arrive via the
10384
+ // HDD-freshness "full" mode.
10385
+ async reconcileStaleFilesFromDisk(paths) {
10386
+ if (!this.cache) return;
10387
+ const scanner = await this.getScanner(true);
10388
+ const metas = await this.refreshStaleFiles(scanner, paths);
10389
+ if (metas.length === 0) return;
10390
+ try {
10391
+ this.cache.upsertFromScannerMeta(metas);
10392
+ } catch (err) {
10393
+ this.log.warn(
10394
+ `stale-file reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
10395
+ { event: "conversations.reconcile_failed" }
10396
+ );
10397
+ }
10355
10398
  }
10356
10399
  async handleListConversations(url, res) {
10357
10400
  if (this.rejectIfWarmingUp(res)) return;
@@ -10361,10 +10404,11 @@ var StreamerServer = class {
10361
10404
  const project = url.searchParams.get("project") ?? void 0;
10362
10405
  const providerFilter = url.searchParams.get("provider") ?? void 0;
10363
10406
  const bustCache = url.searchParams.get("refresh") === "1";
10364
- if (this.cache && (bustCache || this.shouldAutoReconcileConversationList())) {
10407
+ const reconcileMode = this.conversationReconcileMode();
10408
+ if (this.cache && (bustCache || reconcileMode)) {
10365
10409
  const canServeStale = !bustCache && this.cache.listConversations({ limit: 0, offset: 0 }).total > 0;
10366
10410
  if (canServeStale) {
10367
- this.startBackgroundConversationReconcile();
10411
+ this.startBackgroundConversationReconcile(reconcileMode ?? "full");
10368
10412
  } else {
10369
10413
  const shouldEmitProgress = createScanProgressThrottle();
10370
10414
  await this.withWarmup(
@@ -10564,21 +10608,54 @@ var StreamerServer = class {
10564
10608
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
10565
10609
  );
10566
10610
  }
10611
+ // Drain the stale set and disarm the flag together. The caller owns the
10612
+ // returned paths: clearing before the refresh means events that land DURING
10613
+ // it re-arm the flag and get their own pass instead of being swallowed.
10614
+ takeStaleFiles() {
10615
+ const paths = [...this.staleFiles];
10616
+ this.staleFiles.clear();
10617
+ this.scannerStale = false;
10618
+ return paths;
10619
+ }
10620
+ // Reconcile exactly the JSONLs a directory event named. Failures are logged
10621
+ // and swallowed per file: one unreadable transcript must not abort the
10622
+ // others, and the file simply stays on its previous snapshot until the next
10623
+ // event — the same outcome the full rescan gave on a parse failure.
10624
+ async refreshStaleFiles(scanner, paths) {
10625
+ const metas = await Promise.all(
10626
+ paths.map(
10627
+ (filePath) => scanner.refreshFile(filePath).catch((err) => {
10628
+ this.log.warn("scanner.refreshFile: failed", {
10629
+ event: "scanner.refresh_failed",
10630
+ filePath,
10631
+ trigger: "directory-event",
10632
+ err
10633
+ });
10634
+ return null;
10635
+ })
10636
+ )
10637
+ );
10638
+ return metas.filter((m) => m !== null);
10639
+ }
10567
10640
  async getScanner(skipStaleRescan = false) {
10568
10641
  if (this.scannerReady) {
10569
10642
  await this.scannerReady;
10570
10643
  if (this.scanner) {
10571
10644
  if (skipStaleRescan) return this.scanner;
10572
10645
  if (this.scannerStale) {
10573
- this.scannerStale = false;
10574
- this.scanner = null;
10575
- this.scannerReady = null;
10576
- return this.getScanner();
10646
+ const paths = this.takeStaleFiles();
10647
+ if (paths.length === 0) {
10648
+ this.scanner = null;
10649
+ this.scannerReady = null;
10650
+ return this.getScanner();
10651
+ }
10652
+ await this.refreshStaleFiles(this.scanner, paths);
10653
+ return this.scanner ?? this.getScanner();
10577
10654
  }
10578
10655
  return this.scanner;
10579
10656
  }
10580
10657
  }
10581
- this.scannerStale = false;
10658
+ this.takeStaleFiles();
10582
10659
  const statCache = this.buildStatCache(this.scanner);
10583
10660
  this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
10584
10661
  this.allScanners.add(this.scanner);
@@ -10612,7 +10689,7 @@ var StreamerServer = class {
10612
10689
  // getScanner() anti-infinite-loop guard is preserved.
10613
10690
  async rescanForRefresh(onProgress) {
10614
10691
  if (this.scannerReady) await this.scannerReady;
10615
- this.scannerStale = false;
10692
+ this.takeStaleFiles();
10616
10693
  if (!this.scanner) {
10617
10694
  this.scanner = new ConversationScanner();
10618
10695
  this.allScanners.add(this.scanner);