@threadbase-sh/streamer 1.25.0 → 1.27.0

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
@@ -307,6 +307,7 @@ interface ServerConfig {
307
307
  logMenubarRequests?: boolean;
308
308
  browseRoot?: string;
309
309
  publicUrl?: string;
310
+ browserCors?: string;
310
311
  disableDb?: boolean;
311
312
  scanProfiles?: Array<{
312
313
  id: string;
@@ -717,6 +718,7 @@ type ApiDeps = {
717
718
  };
718
719
  publicUrl: string | null;
719
720
  browseRoot: string | null;
721
+ browserCors: string | undefined;
720
722
  ptyManager: LiveSessionManager;
721
723
  sessionStore: SessionStore;
722
724
  wsHub: WSHub;
@@ -745,6 +747,7 @@ type ApiDeps = {
745
747
  handleConversationsCount: (url: URL, res: ServerResponse) => Promise<void>;
746
748
  handleGetConversation: (id: string, url: URL, res: ServerResponse, ifNoneMatch?: string) => Promise<void>;
747
749
  handleSearch: (url: URL, res: ServerResponse) => Promise<void>;
750
+ handleSearchTarget: (id: string, req: IncomingMessage, res: ServerResponse) => Promise<void>;
748
751
  handleListProjects: (url: URL, res: ServerResponse) => void;
749
752
  handleGetPopularProjects: (url: URL, res: ServerResponse) => void;
750
753
  handlePairStart: (res: ServerResponse) => void;
@@ -893,6 +896,7 @@ declare class StreamerServer {
893
896
  private disableDb;
894
897
  private browseRoot;
895
898
  private publicUrl;
899
+ private browserCors;
896
900
  private pairTokens;
897
901
  private exchangeAttempts;
898
902
  private sessionStartAttempts;
@@ -967,6 +971,7 @@ declare class StreamerServer {
967
971
  private findConversationByUuid;
968
972
  private isConversationSnapshotStale;
969
973
  private handleGetConversation;
974
+ private handleSearchTarget;
970
975
  private handleSearch;
971
976
  private handleListSessions;
972
977
  private handleGetSession;
package/dist/index.d.ts CHANGED
@@ -307,6 +307,7 @@ interface ServerConfig {
307
307
  logMenubarRequests?: boolean;
308
308
  browseRoot?: string;
309
309
  publicUrl?: string;
310
+ browserCors?: string;
310
311
  disableDb?: boolean;
311
312
  scanProfiles?: Array<{
312
313
  id: string;
@@ -717,6 +718,7 @@ type ApiDeps = {
717
718
  };
718
719
  publicUrl: string | null;
719
720
  browseRoot: string | null;
721
+ browserCors: string | undefined;
720
722
  ptyManager: LiveSessionManager;
721
723
  sessionStore: SessionStore;
722
724
  wsHub: WSHub;
@@ -745,6 +747,7 @@ type ApiDeps = {
745
747
  handleConversationsCount: (url: URL, res: ServerResponse) => Promise<void>;
746
748
  handleGetConversation: (id: string, url: URL, res: ServerResponse, ifNoneMatch?: string) => Promise<void>;
747
749
  handleSearch: (url: URL, res: ServerResponse) => Promise<void>;
750
+ handleSearchTarget: (id: string, req: IncomingMessage, res: ServerResponse) => Promise<void>;
748
751
  handleListProjects: (url: URL, res: ServerResponse) => void;
749
752
  handleGetPopularProjects: (url: URL, res: ServerResponse) => void;
750
753
  handlePairStart: (res: ServerResponse) => void;
@@ -893,6 +896,7 @@ declare class StreamerServer {
893
896
  private disableDb;
894
897
  private browseRoot;
895
898
  private publicUrl;
899
+ private browserCors;
896
900
  private pairTokens;
897
901
  private exchangeAttempts;
898
902
  private sessionStartAttempts;
@@ -967,6 +971,7 @@ declare class StreamerServer {
967
971
  private findConversationByUuid;
968
972
  private isConversationSnapshotStale;
969
973
  private handleGetConversation;
974
+ private handleSearchTarget;
970
975
  private handleSearch;
971
976
  private handleListSessions;
972
977
  private handleGetSession;
package/dist/index.js CHANGED
@@ -324,6 +324,15 @@ function loadPublicUrl() {
324
324
  }
325
325
  return void 0;
326
326
  }
327
+ function loadBrowserCors() {
328
+ try {
329
+ const content = readFileSync(configFile(), "utf-8");
330
+ const match = content.match(/browser_cors:\s*(.+)/);
331
+ if (match?.[1]) return match[1].trim();
332
+ } catch {
333
+ }
334
+ return void 0;
335
+ }
327
336
  function loadCacheDir() {
328
337
  try {
329
338
  const content = readFileSync(configFile(), "utf-8");
@@ -2574,25 +2583,55 @@ var authMiddleware = (deps) => async (c, next) => {
2574
2583
  };
2575
2584
 
2576
2585
  // src/api/middleware/cors.middleware.ts
2577
- var ALLOWED_ORIGINS = /* @__PURE__ */ new Set([
2586
+ var DEFAULT_DEV_ORIGINS = [
2578
2587
  "http://localhost:8081",
2579
2588
  "http://localhost:19006",
2580
2589
  "http://localhost:3000"
2581
- ]);
2582
- var corsMiddleware = () => async (c, next) => {
2583
- const origin = c.req.header("origin");
2584
- const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : null;
2585
- if (allowedOrigin) {
2586
- c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2587
- c.res.headers.set("Vary", "Origin");
2588
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2589
- c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2590
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
2591
- }
2592
- if (c.req.method === "OPTIONS") {
2593
- return c.newResponse(null, allowedOrigin ? 204 : 403);
2594
- }
2595
- await next();
2590
+ ];
2591
+ function resolveAllowedOrigins(raw) {
2592
+ if (!raw) return null;
2593
+ const trimmed = raw.trim();
2594
+ const lower = trimmed.toLowerCase();
2595
+ if (lower === "0" || lower === "false" || lower === "no" || lower === "off" || trimmed === "") {
2596
+ return null;
2597
+ }
2598
+ const origins = new Set(DEFAULT_DEV_ORIGINS);
2599
+ if (!["1", "true", "yes", "on"].includes(lower)) {
2600
+ for (const o of trimmed.split(",")) {
2601
+ const origin = o.trim();
2602
+ if (origin) origins.add(origin);
2603
+ }
2604
+ }
2605
+ return origins;
2606
+ }
2607
+ var corsMiddleware = (configValue) => {
2608
+ const allowedOrigins = resolveAllowedOrigins(
2609
+ process.env.THREADBASE_ALLOW_BROWSER_CORS ?? configValue
2610
+ );
2611
+ return async (c, next) => {
2612
+ const origin = c.req.header("origin");
2613
+ const allowedOrigin = allowedOrigins && origin && allowedOrigins.has(origin) ? origin : null;
2614
+ if (allowedOrigin) {
2615
+ const raw = c.env.outgoing;
2616
+ raw.setHeader("Access-Control-Allow-Origin", allowedOrigin);
2617
+ raw.setHeader("Vary", "Origin");
2618
+ raw.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, QUERY, OPTIONS");
2619
+ raw.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2620
+ raw.setHeader("Access-Control-Expose-Headers", "ETag, Accept-Query");
2621
+ c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2622
+ c.res.headers.set("Vary", "Origin");
2623
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, QUERY, OPTIONS");
2624
+ c.res.headers.set(
2625
+ "Access-Control-Allow-Headers",
2626
+ "Authorization, Content-Type, If-None-Match"
2627
+ );
2628
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag, Accept-Query");
2629
+ }
2630
+ if (c.req.method === "OPTIONS") {
2631
+ return c.newResponse(null, allowedOrigin ? 204 : 403);
2632
+ }
2633
+ await next();
2634
+ };
2596
2635
  };
2597
2636
 
2598
2637
  // src/api/middleware/error.middleware.ts
@@ -2630,6 +2669,11 @@ var createConversationRoutes = (deps) => {
2630
2669
  await deps.handleConversationsCount(url, c.env.outgoing);
2631
2670
  return alreadyHandled2();
2632
2671
  });
2672
+ app.on("QUERY", "/:id{.+}/search-target", async (c) => {
2673
+ const id = c.req.param("id");
2674
+ await deps.handleSearchTarget(id, c.env.incoming, c.env.outgoing);
2675
+ return alreadyHandled2();
2676
+ });
2633
2677
  app.get("/:id{.+}", async (c) => {
2634
2678
  const id = c.req.param("id");
2635
2679
  const url = new URL(c.req.url);
@@ -3007,7 +3051,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3007
3051
  event: "http.request"
3008
3052
  });
3009
3053
  });
3010
- app.use("*", corsMiddleware());
3054
+ app.use("*", corsMiddleware(deps.browserCors));
3011
3055
  app.use("*", authMiddleware(deps));
3012
3056
  app.onError(errorMiddleware);
3013
3057
  app.route("/healthz", createHealthRoutes());
@@ -4447,6 +4491,55 @@ var ConversationWatcher = class {
4447
4491
  }
4448
4492
  };
4449
4493
 
4494
+ // src/services/conversations/findSearchTarget.ts
4495
+ var SNIPPET_CONTEXT = 60;
4496
+ var MAX_MATCH_INDEXES = 1e3;
4497
+ function buildSnippet(source, matchStart, matchLength) {
4498
+ const start = Math.max(0, matchStart - SNIPPET_CONTEXT);
4499
+ const end = Math.min(source.length, matchStart + matchLength + SNIPPET_CONTEXT);
4500
+ const prefix = start > 0 ? "\u2026" : "";
4501
+ const suffix = end < source.length ? "\u2026" : "";
4502
+ return `${prefix}${source.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
4503
+ }
4504
+ function extendedBody(m) {
4505
+ const parts = [];
4506
+ if (m.isThinking && m.thinkingContent) parts.push(m.thinkingContent);
4507
+ for (const b of m.metadata?.toolUseBlocks ?? []) {
4508
+ if (b.input !== void 0) parts.push(JSON.stringify(b.input));
4509
+ }
4510
+ for (const r of m.metadata?.toolResults ?? []) {
4511
+ if (r.content !== void 0) parts.push(JSON.stringify(r.content));
4512
+ }
4513
+ return parts.join("\n");
4514
+ }
4515
+ function collectMatches(messages, needle, body) {
4516
+ const indexes = [];
4517
+ for (let i = 0; i < messages.length; i++) {
4518
+ if (body(messages[i]).toLowerCase().includes(needle)) indexes.push(i);
4519
+ }
4520
+ return indexes;
4521
+ }
4522
+ function findSearchTarget(messages, query) {
4523
+ const needle = query.toLowerCase();
4524
+ let body = (m) => m.text ?? "";
4525
+ let matches = collectMatches(messages, needle, body);
4526
+ if (matches.length === 0) {
4527
+ body = extendedBody;
4528
+ matches = collectMatches(messages, needle, body);
4529
+ }
4530
+ if (matches.length === 0) return null;
4531
+ const anchorIndex = matches[matches.length - 1];
4532
+ const anchorBody = body(messages[anchorIndex]);
4533
+ const at = anchorBody.toLowerCase().indexOf(needle);
4534
+ return {
4535
+ messageIndex: anchorIndex,
4536
+ uuid: messages[anchorIndex].uuid ?? null,
4537
+ snippet: buildSnippet(anchorBody, at, query.length),
4538
+ matchIndexes: matches.slice(-MAX_MATCH_INDEXES),
4539
+ totalMatches: matches.length
4540
+ };
4541
+ }
4542
+
4450
4543
  // src/services/conversations/pruneAgentConversations.ts
4451
4544
  import { existsSync as existsSync6 } from "fs";
4452
4545
  function pruneAgentConversations(cache) {
@@ -5062,6 +5155,7 @@ var StreamerServer = class {
5062
5155
  disableDb = false;
5063
5156
  browseRoot = null;
5064
5157
  publicUrl = null;
5158
+ browserCors;
5065
5159
  pairTokens = new PairTokenStore();
5066
5160
  exchangeAttempts = /* @__PURE__ */ new Map();
5067
5161
  sessionStartAttempts = /* @__PURE__ */ new Map();
@@ -5147,6 +5241,7 @@ var StreamerServer = class {
5147
5241
  this.log.warn(`Warning: ${result.error}`, { error: result.error });
5148
5242
  }
5149
5243
  }
5244
+ this.browserCors = config.browserCors ?? loadBrowserCors();
5150
5245
  this.sessionStore = new SessionStore();
5151
5246
  this.wsHub = new WSHub();
5152
5247
  this.fileWatcher = new ConversationWatcher({
@@ -5301,6 +5396,7 @@ var StreamerServer = class {
5301
5396
  rotateApiKey: () => this.rotateApiKey(),
5302
5397
  publicUrl: this.publicUrl,
5303
5398
  browseRoot: this.browseRoot,
5399
+ browserCors: this.browserCors,
5304
5400
  ptyManager: this.ptyManager,
5305
5401
  sessionStore: this.sessionStore,
5306
5402
  wsHub: this.wsHub,
@@ -5329,6 +5425,7 @@ var StreamerServer = class {
5329
5425
  handleConversationsCount: (url, res) => this.handleConversationsCount(url, res),
5330
5426
  handleGetConversation: (id, url, res, ifNoneMatch) => this.handleGetConversation(id, url, res, ifNoneMatch),
5331
5427
  handleSearch: (url, res) => this.handleSearch(url, res),
5428
+ handleSearchTarget: (id, req, res) => this.handleSearchTarget(id, req, res),
5332
5429
  handleListProjects: (url, res) => handleListProjects(url, res),
5333
5430
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
5334
5431
  handlePairStart: (res) => this.handlePairStart(res),
@@ -6229,7 +6326,7 @@ var StreamerServer = class {
6229
6326
  messageCount: etagSource.messageCount,
6230
6327
  timestamp: etagSource.timestamp
6231
6328
  });
6232
- const isFirstPage = !url.searchParams.has("before_index");
6329
+ const isFirstPage = !url.searchParams.has("before_index") && !url.searchParams.has("anchor_index") && !url.searchParams.has("after_index");
6233
6330
  if (isFirstPage && ifNoneMatch && ifNoneMatch === etag) {
6234
6331
  res.writeHead(304, { ETag: etag, "Access-Control-Expose-Headers": "ETag" });
6235
6332
  res.end();
@@ -6237,29 +6334,53 @@ var StreamerServer = class {
6237
6334
  }
6238
6335
  const filtered = conversation.messages;
6239
6336
  const total = filtered.length;
6240
- const usePaging = url.searchParams.has("msg_limit") || url.searchParams.has("before_index");
6337
+ const hasAnchor = url.searchParams.has("anchor_index");
6338
+ const hasAfter = url.searchParams.has("after_index");
6339
+ const usePaging = url.searchParams.has("msg_limit") || url.searchParams.has("before_index") || hasAnchor || hasAfter;
6241
6340
  let slice = filtered;
6242
6341
  let fromIdx = 0;
6243
6342
  let messagePagination;
6244
6343
  if (usePaging) {
6245
6344
  const limit = Math.min(Math.max(intParam(url, "msg_limit", 80), 1), 500);
6246
6345
  let beforeIndex = total;
6346
+ let scanLimit = limit;
6347
+ let anchorIndex = null;
6348
+ let newerPaging = false;
6247
6349
  if (url.searchParams.has("before_index")) {
6248
6350
  beforeIndex = intParam(url, "before_index", total);
6249
6351
  beforeIndex = Math.min(Math.max(beforeIndex, 0), total);
6352
+ } else if (hasAfter) {
6353
+ const from = Math.min(Math.max(intParam(url, "after_index", 0), 0), total);
6354
+ beforeIndex = Math.min(total, from + limit);
6355
+ scanLimit = beforeIndex - from;
6356
+ newerPaging = true;
6357
+ } else if (hasAnchor) {
6358
+ anchorIndex = Math.min(
6359
+ Math.max(intParam(url, "anchor_index", 0), 0),
6360
+ Math.max(0, total - 1)
6361
+ );
6362
+ const from = Math.max(0, anchorIndex - Math.floor(limit / 2));
6363
+ beforeIndex = Math.min(total, from + limit);
6364
+ newerPaging = true;
6250
6365
  }
6251
6366
  const pagedScanner = this.scannerReady ? await this.getScanner(true) : null;
6252
- const page = pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit }) : null;
6253
- const start = page?.fromIndex ?? Math.max(0, beforeIndex - limit);
6367
+ const page = scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null;
6368
+ const start = page?.fromIndex ?? Math.max(0, beforeIndex - scanLimit);
6254
6369
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
6255
6370
  fromIdx = start;
6371
+ const effectiveTotal = page?.total ?? total;
6256
6372
  messagePagination = {
6257
- total: page?.total ?? total,
6373
+ total: effectiveTotal,
6258
6374
  before_index: beforeIndex,
6259
6375
  from_index: start,
6260
6376
  has_more_older: start > 0,
6261
6377
  next_before_index: start > 0 ? start : null
6262
6378
  };
6379
+ if (anchorIndex != null) messagePagination.anchor_index = anchorIndex;
6380
+ if (newerPaging) {
6381
+ messagePagination.has_more_newer = beforeIndex < effectiveTotal;
6382
+ messagePagination.next_after_index = beforeIndex < effectiveTotal ? beforeIndex : null;
6383
+ }
6263
6384
  }
6264
6385
  const messagesPayload = slice.map((m, localIdx) => {
6265
6386
  const content = [];
@@ -6334,6 +6455,65 @@ var StreamerServer = class {
6334
6455
  });
6335
6456
  res.end(JSON.stringify(body));
6336
6457
  }
6458
+ // Resolves an active search query to the message a client should anchor to
6459
+ // inside one conversation. Matching is body-only (text first, then
6460
+ // thinking/tool payloads) — a metadata-only search hit (project path, title)
6461
+ // has no scroll target and returns 404 search_target_not_found.
6462
+ //
6463
+ // Implements HTTP QUERY (RFC 10008): the search query travels in a JSON
6464
+ // request body instead of a URL query param — QUERY is safe + idempotent +
6465
+ // cacheable like GET, but (like POST) can carry a body, which fits this
6466
+ // endpoint's single-string input exactly. `Accept-Query` advertises the
6467
+ // supported request media type per the spec.
6468
+ async handleSearchTarget(id, req, res) {
6469
+ const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim();
6470
+ if (contentType && contentType !== "application/json") {
6471
+ res.setHeader("Accept-Query", "application/json");
6472
+ json(res, 415, {
6473
+ error: "Unsupported Content-Type; expected application/json",
6474
+ code: "unsupported_media_type"
6475
+ });
6476
+ return;
6477
+ }
6478
+ let body;
6479
+ try {
6480
+ body = await readBody(req);
6481
+ } catch {
6482
+ res.setHeader("Accept-Query", "application/json");
6483
+ json(res, 422, { error: "Malformed JSON body", code: "invalid_query" });
6484
+ return;
6485
+ }
6486
+ const q = typeof body?.q === "string" ? body.q.trim() : "";
6487
+ if (!q) {
6488
+ res.setHeader("Accept-Query", "application/json");
6489
+ json(res, 422, { error: "Missing or empty query field: q", code: "invalid_query" });
6490
+ return;
6491
+ }
6492
+ if (q.length > 256) {
6493
+ res.setHeader("Accept-Query", "application/json");
6494
+ json(res, 422, { error: "Query too long (max 256 characters)", code: "invalid_query" });
6495
+ return;
6496
+ }
6497
+ const conversation = await this.findConversationByUuid(id);
6498
+ if (!conversation) {
6499
+ json(res, 404, { error: "Conversation not found", code: "not_found" });
6500
+ return;
6501
+ }
6502
+ const target = findSearchTarget(conversation.messages, q);
6503
+ if (!target) {
6504
+ json(res, 404, { error: "No message body matches query", code: "search_target_not_found" });
6505
+ return;
6506
+ }
6507
+ res.setHeader("Accept-Query", "application/json");
6508
+ json(res, 200, {
6509
+ query: q,
6510
+ message_index: target.messageIndex,
6511
+ uuid: target.uuid,
6512
+ snippet: target.snippet,
6513
+ match_indexes: target.matchIndexes,
6514
+ total_matches: target.totalMatches
6515
+ });
6516
+ }
6337
6517
  async handleSearch(url, res) {
6338
6518
  const q = url.searchParams.get("q") ?? "";
6339
6519
  if (!q) {
@@ -6353,7 +6533,11 @@ var StreamerServer = class {
6353
6533
  scanner
6354
6534
  );
6355
6535
  const adapted = results.map((r) => ({
6356
- id: r.meta.id.split("/").pop()?.replace(/\.jsonl$/, "") || r.meta.id,
6536
+ // Use sessionId so the id matches /api/conversations and resolves via
6537
+ // findConversationByUuid — a client can round-trip a search result into
6538
+ // GET /api/conversations/:id or the search-target QUERY. The old
6539
+ // filename-stem derivation produced an id no other endpoint recognized.
6540
+ id: r.meta.sessionId || r.meta.id,
6357
6541
  title: r.meta.projectName,
6358
6542
  sessionName: r.meta.sessionName || void 0,
6359
6543
  filePath: r.meta.filePath,