@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.cjs CHANGED
@@ -8867,6 +8867,20 @@ var StreamerServer = class {
8867
8867
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
8868
8868
  // a single rescan after the current one completes instead of restarting it.
8869
8869
  scannerStale = false;
8870
+ // WHICH files scannerStale is about. A directory event names exactly one
8871
+ // JSONL, and the only correct response is refreshFile() on that one file —
8872
+ // but scannerStale alone carries no identity, so honoring it used to mean a
8873
+ // full-tree rescan. On the non-persistent scanner this server actually runs
8874
+ // (buildStatCache => persistent:false, see listen()), scan() opens by
8875
+ // clearing metadataCache AND conversationLRU — so one live session appending
8876
+ // to its own transcript threw away every OTHER conversation's parsed
8877
+ // snapshot, and the next full fetch of an unrelated conversation re-parsed
8878
+ // it from disk (745-2877ms on a 5MB/1112-message history) while the
8879
+ // per-file paginated path stayed at ~20ms throughout. Populated alongside
8880
+ // scannerStale and drained with it by takeStaleFiles(); an armed flag with
8881
+ // an EMPTY set means "stale, source unknown" and still falls back to the
8882
+ // full rescan.
8883
+ staleFiles = /* @__PURE__ */ new Set();
8870
8884
  // Single-flight guard for the background disk reconcile: a burst of list
8871
8885
  // polls during active session writes shares one rescan instead of queueing
8872
8886
  // a full rescan per request.
@@ -9035,7 +9049,10 @@ var StreamerServer = class {
9035
9049
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9036
9050
  this.markScannerStaleDebounced = debounce(() => {
9037
9051
  if (this.scannerReady) this.scannerStale = true;
9038
- else this.scanner = null;
9052
+ else {
9053
+ this.scanner = null;
9054
+ this.staleFiles.clear();
9055
+ }
9039
9056
  }, this.directoryDebounceMs);
9040
9057
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
9041
9058
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
@@ -9128,6 +9145,7 @@ var StreamerServer = class {
9128
9145
  if (!tailed) this.maybeAttachExternalTail(filePath);
9129
9146
  this.sweepIdleExternalTails();
9130
9147
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
9148
+ this.staleFiles.add(filePath);
9131
9149
  this.markScannerStaleDebounced();
9132
9150
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
9133
9151
  filePath,
@@ -10374,21 +10392,46 @@ var StreamerServer = class {
10374
10392
  // so a burst of list polls during active session writes shares one rescan
10375
10393
  // rather than queueing a full rescan each; tracked so close() awaits the
10376
10394
  // in-flight cache write before shutting the DB.
10377
- startBackgroundConversationReconcile() {
10395
+ startBackgroundConversationReconcile(mode = "full") {
10378
10396
  if (this.conversationReconcileInFlight) return;
10379
- const task = this.reconcileConversationsCacheFromDisk().finally(() => {
10397
+ const paths = mode === "files" ? this.takeStaleFiles() : [];
10398
+ const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileConversationsCacheFromDisk()).finally(() => {
10380
10399
  this.conversationReconcileInFlight = null;
10381
10400
  });
10382
10401
  this.conversationReconcileInFlight = task;
10383
10402
  this.trackCacheWrite(task);
10384
10403
  }
10385
- shouldAutoReconcileConversationList() {
10386
- if (!this.cache) return false;
10387
- if (this.scannerStale) return true;
10388
- if (!this.conversationsRepo || !this.cacheMetadataRepo) return false;
10404
+ // "files": a directory event named specific JSONLs, so refresh only those.
10405
+ // "full": disk drifted in ways a per-file refresh can't see (a project dir
10406
+ // appeared, rows vanished), so walk the tree. Order matters — the staleness
10407
+ // check short-circuits first so the HDD freshness probe stays off the hot
10408
+ // poll path, exactly as it did when this returned a boolean.
10409
+ conversationReconcileMode() {
10410
+ if (!this.cache) return null;
10411
+ if (this.scannerStale) return "files";
10412
+ if (!this.conversationsRepo || !this.cacheMetadataRepo) return null;
10389
10413
  return shouldRefreshProjectsFromHdd(this.conversationsRepo, this.cacheMetadataRepo, {
10390
10414
  projectsDirs: this.projectsDirsForFreshnessCheck()
10391
- });
10415
+ }) ? "full" : null;
10416
+ }
10417
+ // The per-file half of reconcileConversationsCacheFromDisk: re-index just the
10418
+ // changed JSONLs and upsert their rows. No reconcileDeletions here — that
10419
+ // needs the whole live-path set, and deletions already have their own path
10420
+ // (onFileDeleted -> invalidateByFilePath). New projects still arrive via the
10421
+ // HDD-freshness "full" mode.
10422
+ async reconcileStaleFilesFromDisk(paths) {
10423
+ if (!this.cache) return;
10424
+ const scanner = await this.getScanner(true);
10425
+ const metas = await this.refreshStaleFiles(scanner, paths);
10426
+ if (metas.length === 0) return;
10427
+ try {
10428
+ this.cache.upsertFromScannerMeta(metas);
10429
+ } catch (err) {
10430
+ this.log.warn(
10431
+ `stale-file reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
10432
+ { event: "conversations.reconcile_failed" }
10433
+ );
10434
+ }
10392
10435
  }
10393
10436
  async handleListConversations(url, res) {
10394
10437
  if (this.rejectIfWarmingUp(res)) return;
@@ -10398,10 +10441,11 @@ var StreamerServer = class {
10398
10441
  const project = url.searchParams.get("project") ?? void 0;
10399
10442
  const providerFilter = url.searchParams.get("provider") ?? void 0;
10400
10443
  const bustCache = url.searchParams.get("refresh") === "1";
10401
- if (this.cache && (bustCache || this.shouldAutoReconcileConversationList())) {
10444
+ const reconcileMode = this.conversationReconcileMode();
10445
+ if (this.cache && (bustCache || reconcileMode)) {
10402
10446
  const canServeStale = !bustCache && this.cache.listConversations({ limit: 0, offset: 0 }).total > 0;
10403
10447
  if (canServeStale) {
10404
- this.startBackgroundConversationReconcile();
10448
+ this.startBackgroundConversationReconcile(reconcileMode ?? "full");
10405
10449
  } else {
10406
10450
  const shouldEmitProgress = createScanProgressThrottle();
10407
10451
  await this.withWarmup(
@@ -10601,21 +10645,54 @@ var StreamerServer = class {
10601
10645
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
10602
10646
  );
10603
10647
  }
10648
+ // Drain the stale set and disarm the flag together. The caller owns the
10649
+ // returned paths: clearing before the refresh means events that land DURING
10650
+ // it re-arm the flag and get their own pass instead of being swallowed.
10651
+ takeStaleFiles() {
10652
+ const paths = [...this.staleFiles];
10653
+ this.staleFiles.clear();
10654
+ this.scannerStale = false;
10655
+ return paths;
10656
+ }
10657
+ // Reconcile exactly the JSONLs a directory event named. Failures are logged
10658
+ // and swallowed per file: one unreadable transcript must not abort the
10659
+ // others, and the file simply stays on its previous snapshot until the next
10660
+ // event — the same outcome the full rescan gave on a parse failure.
10661
+ async refreshStaleFiles(scanner, paths) {
10662
+ const metas = await Promise.all(
10663
+ paths.map(
10664
+ (filePath) => scanner.refreshFile(filePath).catch((err) => {
10665
+ this.log.warn("scanner.refreshFile: failed", {
10666
+ event: "scanner.refresh_failed",
10667
+ filePath,
10668
+ trigger: "directory-event",
10669
+ err
10670
+ });
10671
+ return null;
10672
+ })
10673
+ )
10674
+ );
10675
+ return metas.filter((m) => m !== null);
10676
+ }
10604
10677
  async getScanner(skipStaleRescan = false) {
10605
10678
  if (this.scannerReady) {
10606
10679
  await this.scannerReady;
10607
10680
  if (this.scanner) {
10608
10681
  if (skipStaleRescan) return this.scanner;
10609
10682
  if (this.scannerStale) {
10610
- this.scannerStale = false;
10611
- this.scanner = null;
10612
- this.scannerReady = null;
10613
- return this.getScanner();
10683
+ const paths = this.takeStaleFiles();
10684
+ if (paths.length === 0) {
10685
+ this.scanner = null;
10686
+ this.scannerReady = null;
10687
+ return this.getScanner();
10688
+ }
10689
+ await this.refreshStaleFiles(this.scanner, paths);
10690
+ return this.scanner ?? this.getScanner();
10614
10691
  }
10615
10692
  return this.scanner;
10616
10693
  }
10617
10694
  }
10618
- this.scannerStale = false;
10695
+ this.takeStaleFiles();
10619
10696
  const statCache = this.buildStatCache(this.scanner);
10620
10697
  this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
10621
10698
  this.allScanners.add(this.scanner);
@@ -10649,7 +10726,7 @@ var StreamerServer = class {
10649
10726
  // getScanner() anti-infinite-loop guard is preserved.
10650
10727
  async rescanForRefresh(onProgress) {
10651
10728
  if (this.scannerReady) await this.scannerReady;
10652
- this.scannerStale = false;
10729
+ this.takeStaleFiles();
10653
10730
  if (!this.scanner) {
10654
10731
  this.scanner = new import_scanner3.ConversationScanner();
10655
10732
  this.allScanners.add(this.scanner);