@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/cli.cjs CHANGED
@@ -146080,6 +146080,20 @@ var StreamerServer = class {
146080
146080
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
146081
146081
  // a single rescan after the current one completes instead of restarting it.
146082
146082
  scannerStale = false;
146083
+ // WHICH files scannerStale is about. A directory event names exactly one
146084
+ // JSONL, and the only correct response is refreshFile() on that one file —
146085
+ // but scannerStale alone carries no identity, so honoring it used to mean a
146086
+ // full-tree rescan. On the non-persistent scanner this server actually runs
146087
+ // (buildStatCache => persistent:false, see listen()), scan() opens by
146088
+ // clearing metadataCache AND conversationLRU — so one live session appending
146089
+ // to its own transcript threw away every OTHER conversation's parsed
146090
+ // snapshot, and the next full fetch of an unrelated conversation re-parsed
146091
+ // it from disk (745-2877ms on a 5MB/1112-message history) while the
146092
+ // per-file paginated path stayed at ~20ms throughout. Populated alongside
146093
+ // scannerStale and drained with it by takeStaleFiles(); an armed flag with
146094
+ // an EMPTY set means "stale, source unknown" and still falls back to the
146095
+ // full rescan.
146096
+ staleFiles = /* @__PURE__ */ new Set();
146083
146097
  // Single-flight guard for the background disk reconcile: a burst of list
146084
146098
  // polls during active session writes shares one rescan instead of queueing
146085
146099
  // a full rescan per request.
@@ -146248,7 +146262,10 @@ var StreamerServer = class {
146248
146262
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config2.directoryScanDebounceMs ?? 1e3;
146249
146263
  this.markScannerStaleDebounced = debounce(() => {
146250
146264
  if (this.scannerReady) this.scannerStale = true;
146251
- else this.scanner = null;
146265
+ else {
146266
+ this.scanner = null;
146267
+ this.staleFiles.clear();
146268
+ }
146252
146269
  }, this.directoryDebounceMs);
146253
146270
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
146254
146271
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
@@ -146341,6 +146358,7 @@ var StreamerServer = class {
146341
146358
  if (!tailed) this.maybeAttachExternalTail(filePath);
146342
146359
  this.sweepIdleExternalTails();
146343
146360
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
146361
+ this.staleFiles.add(filePath);
146344
146362
  this.markScannerStaleDebounced();
146345
146363
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
146346
146364
  filePath,
@@ -147587,21 +147605,46 @@ var StreamerServer = class {
147587
147605
  // so a burst of list polls during active session writes shares one rescan
147588
147606
  // rather than queueing a full rescan each; tracked so close() awaits the
147589
147607
  // in-flight cache write before shutting the DB.
147590
- startBackgroundConversationReconcile() {
147608
+ startBackgroundConversationReconcile(mode = "full") {
147591
147609
  if (this.conversationReconcileInFlight) return;
147592
- const task = this.reconcileConversationsCacheFromDisk().finally(() => {
147610
+ const paths = mode === "files" ? this.takeStaleFiles() : [];
147611
+ const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileConversationsCacheFromDisk()).finally(() => {
147593
147612
  this.conversationReconcileInFlight = null;
147594
147613
  });
147595
147614
  this.conversationReconcileInFlight = task;
147596
147615
  this.trackCacheWrite(task);
147597
147616
  }
147598
- shouldAutoReconcileConversationList() {
147599
- if (!this.cache) return false;
147600
- if (this.scannerStale) return true;
147601
- if (!this.conversationsRepo || !this.cacheMetadataRepo) return false;
147617
+ // "files": a directory event named specific JSONLs, so refresh only those.
147618
+ // "full": disk drifted in ways a per-file refresh can't see (a project dir
147619
+ // appeared, rows vanished), so walk the tree. Order matters — the staleness
147620
+ // check short-circuits first so the HDD freshness probe stays off the hot
147621
+ // poll path, exactly as it did when this returned a boolean.
147622
+ conversationReconcileMode() {
147623
+ if (!this.cache) return null;
147624
+ if (this.scannerStale) return "files";
147625
+ if (!this.conversationsRepo || !this.cacheMetadataRepo) return null;
147602
147626
  return shouldRefreshProjectsFromHdd(this.conversationsRepo, this.cacheMetadataRepo, {
147603
147627
  projectsDirs: this.projectsDirsForFreshnessCheck()
147604
- });
147628
+ }) ? "full" : null;
147629
+ }
147630
+ // The per-file half of reconcileConversationsCacheFromDisk: re-index just the
147631
+ // changed JSONLs and upsert their rows. No reconcileDeletions here — that
147632
+ // needs the whole live-path set, and deletions already have their own path
147633
+ // (onFileDeleted -> invalidateByFilePath). New projects still arrive via the
147634
+ // HDD-freshness "full" mode.
147635
+ async reconcileStaleFilesFromDisk(paths) {
147636
+ if (!this.cache) return;
147637
+ const scanner = await this.getScanner(true);
147638
+ const metas = await this.refreshStaleFiles(scanner, paths);
147639
+ if (metas.length === 0) return;
147640
+ try {
147641
+ this.cache.upsertFromScannerMeta(metas);
147642
+ } catch (err) {
147643
+ this.log.warn(
147644
+ `stale-file reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
147645
+ { event: "conversations.reconcile_failed" }
147646
+ );
147647
+ }
147605
147648
  }
147606
147649
  async handleListConversations(url2, res) {
147607
147650
  if (this.rejectIfWarmingUp(res)) return;
@@ -147611,10 +147654,11 @@ var StreamerServer = class {
147611
147654
  const project = url2.searchParams.get("project") ?? void 0;
147612
147655
  const providerFilter = url2.searchParams.get("provider") ?? void 0;
147613
147656
  const bustCache = url2.searchParams.get("refresh") === "1";
147614
- if (this.cache && (bustCache || this.shouldAutoReconcileConversationList())) {
147657
+ const reconcileMode = this.conversationReconcileMode();
147658
+ if (this.cache && (bustCache || reconcileMode)) {
147615
147659
  const canServeStale = !bustCache && this.cache.listConversations({ limit: 0, offset: 0 }).total > 0;
147616
147660
  if (canServeStale) {
147617
- this.startBackgroundConversationReconcile();
147661
+ this.startBackgroundConversationReconcile(reconcileMode ?? "full");
147618
147662
  } else {
147619
147663
  const shouldEmitProgress = createScanProgressThrottle();
147620
147664
  await this.withWarmup(
@@ -147814,21 +147858,54 @@ var StreamerServer = class {
147814
147858
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
147815
147859
  );
147816
147860
  }
147861
+ // Drain the stale set and disarm the flag together. The caller owns the
147862
+ // returned paths: clearing before the refresh means events that land DURING
147863
+ // it re-arm the flag and get their own pass instead of being swallowed.
147864
+ takeStaleFiles() {
147865
+ const paths = [...this.staleFiles];
147866
+ this.staleFiles.clear();
147867
+ this.scannerStale = false;
147868
+ return paths;
147869
+ }
147870
+ // Reconcile exactly the JSONLs a directory event named. Failures are logged
147871
+ // and swallowed per file: one unreadable transcript must not abort the
147872
+ // others, and the file simply stays on its previous snapshot until the next
147873
+ // event — the same outcome the full rescan gave on a parse failure.
147874
+ async refreshStaleFiles(scanner, paths) {
147875
+ const metas = await Promise.all(
147876
+ paths.map(
147877
+ (filePath) => scanner.refreshFile(filePath).catch((err) => {
147878
+ this.log.warn("scanner.refreshFile: failed", {
147879
+ event: "scanner.refresh_failed",
147880
+ filePath,
147881
+ trigger: "directory-event",
147882
+ err
147883
+ });
147884
+ return null;
147885
+ })
147886
+ )
147887
+ );
147888
+ return metas.filter((m2) => m2 !== null);
147889
+ }
147817
147890
  async getScanner(skipStaleRescan = false) {
147818
147891
  if (this.scannerReady) {
147819
147892
  await this.scannerReady;
147820
147893
  if (this.scanner) {
147821
147894
  if (skipStaleRescan) return this.scanner;
147822
147895
  if (this.scannerStale) {
147823
- this.scannerStale = false;
147824
- this.scanner = null;
147825
- this.scannerReady = null;
147826
- return this.getScanner();
147896
+ const paths = this.takeStaleFiles();
147897
+ if (paths.length === 0) {
147898
+ this.scanner = null;
147899
+ this.scannerReady = null;
147900
+ return this.getScanner();
147901
+ }
147902
+ await this.refreshStaleFiles(this.scanner, paths);
147903
+ return this.scanner ?? this.getScanner();
147827
147904
  }
147828
147905
  return this.scanner;
147829
147906
  }
147830
147907
  }
147831
- this.scannerStale = false;
147908
+ this.takeStaleFiles();
147832
147909
  const statCache = this.buildStatCache(this.scanner);
147833
147910
  this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
147834
147911
  this.allScanners.add(this.scanner);
@@ -147862,7 +147939,7 @@ var StreamerServer = class {
147862
147939
  // getScanner() anti-infinite-loop guard is preserved.
147863
147940
  async rescanForRefresh(onProgress) {
147864
147941
  if (this.scannerReady) await this.scannerReady;
147865
- this.scannerStale = false;
147942
+ this.takeStaleFiles();
147866
147943
  if (!this.scanner) {
147867
147944
  this.scanner = new ConversationScanner();
147868
147945
  this.allScanners.add(this.scanner);