@threadbase-sh/streamer 1.45.0 → 1.46.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
@@ -5345,7 +5345,10 @@ var createMiscRoutes = (deps) => {
5345
5345
  // Same contract: this server serves GET /api/config/feature-flags. Lives
5346
5346
  // here rather than behind /api/config (admin-only) so a read-only client
5347
5347
  // still learns the server supports flags even if it can't read values.
5348
- featureFlags: true
5348
+ featureFlags: true,
5349
+ // Same contract: this server serves GET /api/projects/summary, which the
5350
+ // Hub's grouped views need before they can draw a tree.
5351
+ projectSummary: true
5349
5352
  });
5350
5353
  });
5351
5354
  app.get("/api/profiles", (c) => c.json([]));
@@ -5492,6 +5495,11 @@ var createProjectRoutes = (deps) => {
5492
5495
  deps.handleGetPopularProjects(url, c.env.outgoing);
5493
5496
  return alreadyHandled4();
5494
5497
  });
5498
+ app.get("/summary", (c) => {
5499
+ const url = new URL(c.req.url);
5500
+ deps.handleGetProjectSummaries(url, c.env.outgoing);
5501
+ return alreadyHandled4();
5502
+ });
5495
5503
  return app;
5496
5504
  };
5497
5505
 
@@ -6357,6 +6365,21 @@ var ConversationCache = class _ConversationCache {
6357
6365
  ORDER BY cnt DESC
6358
6366
  LIMIT ?`
6359
6367
  ),
6368
+ // Same table, same rows and same NULL filter /api/conversations lists
6369
+ // from, so a group's count/last-activity can never disagree with the
6370
+ // page it opens. Bare project_name is the one from the MAX(last_activity)
6371
+ // row (SQLite's documented min/max-aggregate bare-column rule).
6372
+ projectSummaries: db.prepare(
6373
+ `SELECT project_path, project_name, COUNT(*) as cnt, MAX(last_activity) as latest
6374
+ FROM conversation_meta
6375
+ WHERE project_path IS NOT NULL
6376
+ GROUP BY project_path
6377
+ ORDER BY latest DESC, project_path ASC
6378
+ LIMIT ? OFFSET ?`
6379
+ ),
6380
+ projectSummaryCount: db.prepare(
6381
+ "SELECT COUNT(DISTINCT project_path) as n FROM conversation_meta WHERE project_path IS NOT NULL"
6382
+ ),
6360
6383
  getFileState: db.prepare("SELECT * FROM conversation_file_state WHERE path = ?"),
6361
6384
  upsertFileState: db.prepare(
6362
6385
  `INSERT INTO conversation_file_state
@@ -6732,6 +6755,23 @@ var ConversationCache = class _ConversationCache {
6732
6755
  sessionCount: r.cnt
6733
6756
  }));
6734
6757
  }
6758
+ /** Every project with at least one cached conversation, most recently active
6759
+ * first. Paths are the raw `project_path` values, which is what
6760
+ * /api/conversations?project= matches on exactly — so a summary row is
6761
+ * always joinable against the page it describes. */
6762
+ listProjectSummaries(opts) {
6763
+ const total = this.stmts.projectSummaryCount.get().n;
6764
+ const rows = opts.limit === 0 ? [] : this.stmts.projectSummaries.all(opts.limit, opts.offset);
6765
+ return {
6766
+ total,
6767
+ projects: rows.map((r) => ({
6768
+ path: r.project_path,
6769
+ name: r.project_name ?? r.project_path.split(/[/\\]/).pop() ?? r.project_path,
6770
+ conversationCount: r.cnt,
6771
+ lastActivity: new Date(r.latest ?? 0).toISOString()
6772
+ }))
6773
+ };
6774
+ }
6735
6775
  ensureFileIndex() {
6736
6776
  if (this.fileIndexLoaded) return;
6737
6777
  const rows = this.stmts.allFilePaths.all();
@@ -8384,6 +8424,27 @@ var ConversationWatcher = class {
8384
8424
  * Watch a directory of conversation JSONL files. Fires
8385
8425
  * onConversationChanged for any add/change/unlink event so the caller
8386
8426
  * can mark the cache dirty without scanning everything immediately.
8427
+ *
8428
+ * **This costs one OS watch handle per file under `directory`, not one per
8429
+ * directory.** chokidar recurses the tree and registers a separate fs.watch
8430
+ * per entry, because a directory watch alone does not report writes to files
8431
+ * inside it — and per-file `change` events are exactly what the caller needs
8432
+ * (they drive poke()'s tail self-heal and the external-tail attach). So the
8433
+ * handle count tracks the size of the conversation corpus on disk, not the
8434
+ * number of live sessions, and it does not shrink until transcripts are
8435
+ * deleted. `ignoreInitial` suppresses the startup *events*, not the walk.
8436
+ *
8437
+ * Measured 2026-08-09 on the live macOS instance: 2131 open .jsonl fds
8438
+ * against 2133 files under the watched roots — 1:1, ~88% of all fds on the
8439
+ * process, at 2.0% of that box's 122 880 per-process ceiling. Comfortable
8440
+ * there. **Linux is the tight one**: these are inotify watches billed to the
8441
+ * per-user `max_user_watches`, which can be 8192 and is shared with every
8442
+ * other watcher the user runs. Exhaustion surfaces as ENOSPC on the `error`
8443
+ * event — which is why server.ts wires onError rather than leaving it unset.
8444
+ *
8445
+ * Before trading handles for a bound here, note the regression it invites: a
8446
+ * conversation excluded from the walk (by age or by an LRU cap) is one whose
8447
+ * external appends produce no event at all, and that failure is silent.
8387
8448
  */
8388
8449
  watchDirectory(directory) {
8389
8450
  if (this.directories.has(directory)) return;
@@ -10680,6 +10741,13 @@ var StreamerServer = class {
10680
10741
  event: "cache.invalidate_on_unlink"
10681
10742
  });
10682
10743
  this.cacheMonitor?.recordUnlink(filePath);
10744
+ },
10745
+ onError: (filePath, err) => {
10746
+ const enospc = err.code === "ENOSPC";
10747
+ this.log.error(
10748
+ enospc ? `Watcher hit the OS watch-handle limit on ${filePath} \u2014 raise fs.inotify.max_user_watches (Linux) or the process fd limit; conversation discovery and live tails are degraded until then` : `Watcher error on ${filePath}: ${err.message}`,
10749
+ { filePath, err, event: enospc ? "watcher.limit_exhausted" : "watcher.error" }
10750
+ );
10683
10751
  }
10684
10752
  });
10685
10753
  this.ptyManager = new LiveSessionManager({
@@ -10873,6 +10941,7 @@ var StreamerServer = class {
10873
10941
  handleSearchTarget: (id, req, res) => this.handleSearchTarget(id, req, res),
10874
10942
  handleListProjects: (url, res) => handleListProjects(url, res),
10875
10943
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
10944
+ handleGetProjectSummaries: (url, res) => this.handleGetProjectSummaries(url, res),
10876
10945
  handlePairStart: (res) => this.handlePairStart(res),
10877
10946
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
10878
10947
  handleBrowse: (url, res) => this.handleBrowse(url, res),
@@ -12438,6 +12507,20 @@ var StreamerServer = class {
12438
12507
  const projects = this.cache.getPopularProjects(limit);
12439
12508
  json(res, 200, { projects, total: projects.length });
12440
12509
  }
12510
+ handleGetProjectSummaries(url, res) {
12511
+ if (this.rejectIfWarmingUp(res)) return;
12512
+ const limit = intParam(url, "limit", 200);
12513
+ const offset = intParam(url, "offset", 0);
12514
+ if (!this.cache) {
12515
+ json(res, 503, {
12516
+ error: "Conversation cache unavailable",
12517
+ code: "CACHE_UNAVAILABLE"
12518
+ });
12519
+ return;
12520
+ }
12521
+ const { projects, total } = this.cache.listProjectSummaries({ limit, offset });
12522
+ json(res, 200, { projects, total, offset, hasMore: offset + projects.length < total });
12523
+ }
12441
12524
  buildStatCache(previousScanner) {
12442
12525
  if (!this.cache) return void 0;
12443
12526
  if (!previousScanner) {