@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.d.cts CHANGED
@@ -824,6 +824,22 @@ declare class ConversationCache {
824
824
  name: string;
825
825
  sessionCount: number;
826
826
  }>;
827
+ /** Every project with at least one cached conversation, most recently active
828
+ * first. Paths are the raw `project_path` values, which is what
829
+ * /api/conversations?project= matches on exactly — so a summary row is
830
+ * always joinable against the page it describes. */
831
+ listProjectSummaries(opts: {
832
+ limit: number;
833
+ offset: number;
834
+ }): {
835
+ projects: Array<{
836
+ path: string;
837
+ name: string;
838
+ conversationCount: number;
839
+ lastActivity: string;
840
+ }>;
841
+ total: number;
842
+ };
827
843
  private ensureFileIndex;
828
844
  updateFromLine(filePath: string, rawLine: string): void;
829
845
  /**
@@ -1689,6 +1705,7 @@ type ApiDeps = {
1689
1705
  handleSearchTarget: (id: string, req: IncomingMessage, res: ServerResponse) => Promise<void>;
1690
1706
  handleListProjects: (url: URL, res: ServerResponse) => void;
1691
1707
  handleGetPopularProjects: (url: URL, res: ServerResponse) => void;
1708
+ handleGetProjectSummaries: (url: URL, res: ServerResponse) => void;
1692
1709
  handlePairStart: (res: ServerResponse) => void;
1693
1710
  handlePairExchange: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
1694
1711
  handleBrowse: (url: URL, res: ServerResponse) => Promise<void>;
@@ -2119,6 +2136,7 @@ declare class StreamerServer {
2119
2136
  private handleSessionsCount;
2120
2137
  private handleGetRecentSessions;
2121
2138
  private handleGetPopularProjects;
2139
+ private handleGetProjectSummaries;
2122
2140
  private buildStatCache;
2123
2141
  private codexScanOpts;
2124
2142
  private newScanner;
@@ -2322,6 +2340,27 @@ declare class ConversationWatcher {
2322
2340
  * Watch a directory of conversation JSONL files. Fires
2323
2341
  * onConversationChanged for any add/change/unlink event so the caller
2324
2342
  * can mark the cache dirty without scanning everything immediately.
2343
+ *
2344
+ * **This costs one OS watch handle per file under `directory`, not one per
2345
+ * directory.** chokidar recurses the tree and registers a separate fs.watch
2346
+ * per entry, because a directory watch alone does not report writes to files
2347
+ * inside it — and per-file `change` events are exactly what the caller needs
2348
+ * (they drive poke()'s tail self-heal and the external-tail attach). So the
2349
+ * handle count tracks the size of the conversation corpus on disk, not the
2350
+ * number of live sessions, and it does not shrink until transcripts are
2351
+ * deleted. `ignoreInitial` suppresses the startup *events*, not the walk.
2352
+ *
2353
+ * Measured 2026-08-09 on the live macOS instance: 2131 open .jsonl fds
2354
+ * against 2133 files under the watched roots — 1:1, ~88% of all fds on the
2355
+ * process, at 2.0% of that box's 122 880 per-process ceiling. Comfortable
2356
+ * there. **Linux is the tight one**: these are inotify watches billed to the
2357
+ * per-user `max_user_watches`, which can be 8192 and is shared with every
2358
+ * other watcher the user runs. Exhaustion surfaces as ENOSPC on the `error`
2359
+ * event — which is why server.ts wires onError rather than leaving it unset.
2360
+ *
2361
+ * Before trading handles for a bound here, note the regression it invites: a
2362
+ * conversation excluded from the walk (by age or by an LRU cap) is one whose
2363
+ * external appends produce no event at all, and that failure is silent.
2325
2364
  */
2326
2365
  watchDirectory(directory: string): void;
2327
2366
  unwatchDirectory(directory: string): void;
package/dist/index.d.ts CHANGED
@@ -824,6 +824,22 @@ declare class ConversationCache {
824
824
  name: string;
825
825
  sessionCount: number;
826
826
  }>;
827
+ /** Every project with at least one cached conversation, most recently active
828
+ * first. Paths are the raw `project_path` values, which is what
829
+ * /api/conversations?project= matches on exactly — so a summary row is
830
+ * always joinable against the page it describes. */
831
+ listProjectSummaries(opts: {
832
+ limit: number;
833
+ offset: number;
834
+ }): {
835
+ projects: Array<{
836
+ path: string;
837
+ name: string;
838
+ conversationCount: number;
839
+ lastActivity: string;
840
+ }>;
841
+ total: number;
842
+ };
827
843
  private ensureFileIndex;
828
844
  updateFromLine(filePath: string, rawLine: string): void;
829
845
  /**
@@ -1689,6 +1705,7 @@ type ApiDeps = {
1689
1705
  handleSearchTarget: (id: string, req: IncomingMessage, res: ServerResponse) => Promise<void>;
1690
1706
  handleListProjects: (url: URL, res: ServerResponse) => void;
1691
1707
  handleGetPopularProjects: (url: URL, res: ServerResponse) => void;
1708
+ handleGetProjectSummaries: (url: URL, res: ServerResponse) => void;
1692
1709
  handlePairStart: (res: ServerResponse) => void;
1693
1710
  handlePairExchange: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
1694
1711
  handleBrowse: (url: URL, res: ServerResponse) => Promise<void>;
@@ -2119,6 +2136,7 @@ declare class StreamerServer {
2119
2136
  private handleSessionsCount;
2120
2137
  private handleGetRecentSessions;
2121
2138
  private handleGetPopularProjects;
2139
+ private handleGetProjectSummaries;
2122
2140
  private buildStatCache;
2123
2141
  private codexScanOpts;
2124
2142
  private newScanner;
@@ -2322,6 +2340,27 @@ declare class ConversationWatcher {
2322
2340
  * Watch a directory of conversation JSONL files. Fires
2323
2341
  * onConversationChanged for any add/change/unlink event so the caller
2324
2342
  * can mark the cache dirty without scanning everything immediately.
2343
+ *
2344
+ * **This costs one OS watch handle per file under `directory`, not one per
2345
+ * directory.** chokidar recurses the tree and registers a separate fs.watch
2346
+ * per entry, because a directory watch alone does not report writes to files
2347
+ * inside it — and per-file `change` events are exactly what the caller needs
2348
+ * (they drive poke()'s tail self-heal and the external-tail attach). So the
2349
+ * handle count tracks the size of the conversation corpus on disk, not the
2350
+ * number of live sessions, and it does not shrink until transcripts are
2351
+ * deleted. `ignoreInitial` suppresses the startup *events*, not the walk.
2352
+ *
2353
+ * Measured 2026-08-09 on the live macOS instance: 2131 open .jsonl fds
2354
+ * against 2133 files under the watched roots — 1:1, ~88% of all fds on the
2355
+ * process, at 2.0% of that box's 122 880 per-process ceiling. Comfortable
2356
+ * there. **Linux is the tight one**: these are inotify watches billed to the
2357
+ * per-user `max_user_watches`, which can be 8192 and is shared with every
2358
+ * other watcher the user runs. Exhaustion surfaces as ENOSPC on the `error`
2359
+ * event — which is why server.ts wires onError rather than leaving it unset.
2360
+ *
2361
+ * Before trading handles for a bound here, note the regression it invites: a
2362
+ * conversation excluded from the walk (by age or by an LRU cap) is one whose
2363
+ * external appends produce no event at all, and that failure is silent.
2325
2364
  */
2326
2365
  watchDirectory(directory: string): void;
2327
2366
  unwatchDirectory(directory: string): void;
package/dist/index.js CHANGED
@@ -5306,7 +5306,10 @@ var createMiscRoutes = (deps) => {
5306
5306
  // Same contract: this server serves GET /api/config/feature-flags. Lives
5307
5307
  // here rather than behind /api/config (admin-only) so a read-only client
5308
5308
  // still learns the server supports flags even if it can't read values.
5309
- featureFlags: true
5309
+ featureFlags: true,
5310
+ // Same contract: this server serves GET /api/projects/summary, which the
5311
+ // Hub's grouped views need before they can draw a tree.
5312
+ projectSummary: true
5310
5313
  });
5311
5314
  });
5312
5315
  app.get("/api/profiles", (c) => c.json([]));
@@ -5453,6 +5456,11 @@ var createProjectRoutes = (deps) => {
5453
5456
  deps.handleGetPopularProjects(url, c.env.outgoing);
5454
5457
  return alreadyHandled4();
5455
5458
  });
5459
+ app.get("/summary", (c) => {
5460
+ const url = new URL(c.req.url);
5461
+ deps.handleGetProjectSummaries(url, c.env.outgoing);
5462
+ return alreadyHandled4();
5463
+ });
5456
5464
  return app;
5457
5465
  };
5458
5466
 
@@ -6320,6 +6328,21 @@ var ConversationCache = class _ConversationCache {
6320
6328
  ORDER BY cnt DESC
6321
6329
  LIMIT ?`
6322
6330
  ),
6331
+ // Same table, same rows and same NULL filter /api/conversations lists
6332
+ // from, so a group's count/last-activity can never disagree with the
6333
+ // page it opens. Bare project_name is the one from the MAX(last_activity)
6334
+ // row (SQLite's documented min/max-aggregate bare-column rule).
6335
+ projectSummaries: db.prepare(
6336
+ `SELECT project_path, project_name, COUNT(*) as cnt, MAX(last_activity) as latest
6337
+ FROM conversation_meta
6338
+ WHERE project_path IS NOT NULL
6339
+ GROUP BY project_path
6340
+ ORDER BY latest DESC, project_path ASC
6341
+ LIMIT ? OFFSET ?`
6342
+ ),
6343
+ projectSummaryCount: db.prepare(
6344
+ "SELECT COUNT(DISTINCT project_path) as n FROM conversation_meta WHERE project_path IS NOT NULL"
6345
+ ),
6323
6346
  getFileState: db.prepare("SELECT * FROM conversation_file_state WHERE path = ?"),
6324
6347
  upsertFileState: db.prepare(
6325
6348
  `INSERT INTO conversation_file_state
@@ -6695,6 +6718,23 @@ var ConversationCache = class _ConversationCache {
6695
6718
  sessionCount: r.cnt
6696
6719
  }));
6697
6720
  }
6721
+ /** Every project with at least one cached conversation, most recently active
6722
+ * first. Paths are the raw `project_path` values, which is what
6723
+ * /api/conversations?project= matches on exactly — so a summary row is
6724
+ * always joinable against the page it describes. */
6725
+ listProjectSummaries(opts) {
6726
+ const total = this.stmts.projectSummaryCount.get().n;
6727
+ const rows = opts.limit === 0 ? [] : this.stmts.projectSummaries.all(opts.limit, opts.offset);
6728
+ return {
6729
+ total,
6730
+ projects: rows.map((r) => ({
6731
+ path: r.project_path,
6732
+ name: r.project_name ?? r.project_path.split(/[/\\]/).pop() ?? r.project_path,
6733
+ conversationCount: r.cnt,
6734
+ lastActivity: new Date(r.latest ?? 0).toISOString()
6735
+ }))
6736
+ };
6737
+ }
6698
6738
  ensureFileIndex() {
6699
6739
  if (this.fileIndexLoaded) return;
6700
6740
  const rows = this.stmts.allFilePaths.all();
@@ -8347,6 +8387,27 @@ var ConversationWatcher = class {
8347
8387
  * Watch a directory of conversation JSONL files. Fires
8348
8388
  * onConversationChanged for any add/change/unlink event so the caller
8349
8389
  * can mark the cache dirty without scanning everything immediately.
8390
+ *
8391
+ * **This costs one OS watch handle per file under `directory`, not one per
8392
+ * directory.** chokidar recurses the tree and registers a separate fs.watch
8393
+ * per entry, because a directory watch alone does not report writes to files
8394
+ * inside it — and per-file `change` events are exactly what the caller needs
8395
+ * (they drive poke()'s tail self-heal and the external-tail attach). So the
8396
+ * handle count tracks the size of the conversation corpus on disk, not the
8397
+ * number of live sessions, and it does not shrink until transcripts are
8398
+ * deleted. `ignoreInitial` suppresses the startup *events*, not the walk.
8399
+ *
8400
+ * Measured 2026-08-09 on the live macOS instance: 2131 open .jsonl fds
8401
+ * against 2133 files under the watched roots — 1:1, ~88% of all fds on the
8402
+ * process, at 2.0% of that box's 122 880 per-process ceiling. Comfortable
8403
+ * there. **Linux is the tight one**: these are inotify watches billed to the
8404
+ * per-user `max_user_watches`, which can be 8192 and is shared with every
8405
+ * other watcher the user runs. Exhaustion surfaces as ENOSPC on the `error`
8406
+ * event — which is why server.ts wires onError rather than leaving it unset.
8407
+ *
8408
+ * Before trading handles for a bound here, note the regression it invites: a
8409
+ * conversation excluded from the walk (by age or by an LRU cap) is one whose
8410
+ * external appends produce no event at all, and that failure is silent.
8350
8411
  */
8351
8412
  watchDirectory(directory) {
8352
8413
  if (this.directories.has(directory)) return;
@@ -10643,6 +10704,13 @@ var StreamerServer = class {
10643
10704
  event: "cache.invalidate_on_unlink"
10644
10705
  });
10645
10706
  this.cacheMonitor?.recordUnlink(filePath);
10707
+ },
10708
+ onError: (filePath, err) => {
10709
+ const enospc = err.code === "ENOSPC";
10710
+ this.log.error(
10711
+ 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}`,
10712
+ { filePath, err, event: enospc ? "watcher.limit_exhausted" : "watcher.error" }
10713
+ );
10646
10714
  }
10647
10715
  });
10648
10716
  this.ptyManager = new LiveSessionManager({
@@ -10836,6 +10904,7 @@ var StreamerServer = class {
10836
10904
  handleSearchTarget: (id, req, res) => this.handleSearchTarget(id, req, res),
10837
10905
  handleListProjects: (url, res) => handleListProjects(url, res),
10838
10906
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
10907
+ handleGetProjectSummaries: (url, res) => this.handleGetProjectSummaries(url, res),
10839
10908
  handlePairStart: (res) => this.handlePairStart(res),
10840
10909
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
10841
10910
  handleBrowse: (url, res) => this.handleBrowse(url, res),
@@ -12401,6 +12470,20 @@ var StreamerServer = class {
12401
12470
  const projects = this.cache.getPopularProjects(limit);
12402
12471
  json(res, 200, { projects, total: projects.length });
12403
12472
  }
12473
+ handleGetProjectSummaries(url, res) {
12474
+ if (this.rejectIfWarmingUp(res)) return;
12475
+ const limit = intParam(url, "limit", 200);
12476
+ const offset = intParam(url, "offset", 0);
12477
+ if (!this.cache) {
12478
+ json(res, 503, {
12479
+ error: "Conversation cache unavailable",
12480
+ code: "CACHE_UNAVAILABLE"
12481
+ });
12482
+ return;
12483
+ }
12484
+ const { projects, total } = this.cache.listProjectSummaries({ limit, offset });
12485
+ json(res, 200, { projects, total, offset, hasMore: offset + projects.length < total });
12486
+ }
12404
12487
  buildStatCache(previousScanner) {
12405
12488
  if (!this.cache) return void 0;
12406
12489
  if (!previousScanner) {