@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.cjs CHANGED
@@ -375,6 +375,15 @@ function loadPublicUrl() {
375
375
  }
376
376
  return void 0;
377
377
  }
378
+ function loadBrowserCors() {
379
+ try {
380
+ const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
381
+ const match = content.match(/browser_cors:\s*(.+)/);
382
+ if (match?.[1]) return match[1].trim();
383
+ } catch {
384
+ }
385
+ return void 0;
386
+ }
378
387
  function loadCacheDir() {
379
388
  try {
380
389
  const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
@@ -2612,25 +2621,55 @@ var authMiddleware = (deps) => async (c, next) => {
2612
2621
  };
2613
2622
 
2614
2623
  // src/api/middleware/cors.middleware.ts
2615
- var ALLOWED_ORIGINS = /* @__PURE__ */ new Set([
2624
+ var DEFAULT_DEV_ORIGINS = [
2616
2625
  "http://localhost:8081",
2617
2626
  "http://localhost:19006",
2618
2627
  "http://localhost:3000"
2619
- ]);
2620
- var corsMiddleware = () => async (c, next) => {
2621
- const origin = c.req.header("origin");
2622
- const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : null;
2623
- if (allowedOrigin) {
2624
- c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2625
- c.res.headers.set("Vary", "Origin");
2626
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2627
- c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2628
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
2629
- }
2630
- if (c.req.method === "OPTIONS") {
2631
- return c.newResponse(null, allowedOrigin ? 204 : 403);
2632
- }
2633
- await next();
2628
+ ];
2629
+ function resolveAllowedOrigins(raw) {
2630
+ if (!raw) return null;
2631
+ const trimmed = raw.trim();
2632
+ const lower = trimmed.toLowerCase();
2633
+ if (lower === "0" || lower === "false" || lower === "no" || lower === "off" || trimmed === "") {
2634
+ return null;
2635
+ }
2636
+ const origins = new Set(DEFAULT_DEV_ORIGINS);
2637
+ if (!["1", "true", "yes", "on"].includes(lower)) {
2638
+ for (const o of trimmed.split(",")) {
2639
+ const origin = o.trim();
2640
+ if (origin) origins.add(origin);
2641
+ }
2642
+ }
2643
+ return origins;
2644
+ }
2645
+ var corsMiddleware = (configValue) => {
2646
+ const allowedOrigins = resolveAllowedOrigins(
2647
+ process.env.THREADBASE_ALLOW_BROWSER_CORS ?? configValue
2648
+ );
2649
+ return async (c, next) => {
2650
+ const origin = c.req.header("origin");
2651
+ const allowedOrigin = allowedOrigins && origin && allowedOrigins.has(origin) ? origin : null;
2652
+ if (allowedOrigin) {
2653
+ const raw = c.env.outgoing;
2654
+ raw.setHeader("Access-Control-Allow-Origin", allowedOrigin);
2655
+ raw.setHeader("Vary", "Origin");
2656
+ raw.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, QUERY, OPTIONS");
2657
+ raw.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2658
+ raw.setHeader("Access-Control-Expose-Headers", "ETag, Accept-Query");
2659
+ c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2660
+ c.res.headers.set("Vary", "Origin");
2661
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, QUERY, OPTIONS");
2662
+ c.res.headers.set(
2663
+ "Access-Control-Allow-Headers",
2664
+ "Authorization, Content-Type, If-None-Match"
2665
+ );
2666
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag, Accept-Query");
2667
+ }
2668
+ if (c.req.method === "OPTIONS") {
2669
+ return c.newResponse(null, allowedOrigin ? 204 : 403);
2670
+ }
2671
+ await next();
2672
+ };
2634
2673
  };
2635
2674
 
2636
2675
  // src/api/middleware/error.middleware.ts
@@ -2668,6 +2707,11 @@ var createConversationRoutes = (deps) => {
2668
2707
  await deps.handleConversationsCount(url, c.env.outgoing);
2669
2708
  return alreadyHandled2();
2670
2709
  });
2710
+ app.on("QUERY", "/:id{.+}/search-target", async (c) => {
2711
+ const id = c.req.param("id");
2712
+ await deps.handleSearchTarget(id, c.env.incoming, c.env.outgoing);
2713
+ return alreadyHandled2();
2714
+ });
2671
2715
  app.get("/:id{.+}", async (c) => {
2672
2716
  const id = c.req.param("id");
2673
2717
  const url = new URL(c.req.url);
@@ -3045,7 +3089,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
3045
3089
  event: "http.request"
3046
3090
  });
3047
3091
  });
3048
- app.use("*", corsMiddleware());
3092
+ app.use("*", corsMiddleware(deps.browserCors));
3049
3093
  app.use("*", authMiddleware(deps));
3050
3094
  app.onError(errorMiddleware);
3051
3095
  app.route("/healthz", createHealthRoutes());
@@ -4486,6 +4530,55 @@ var ConversationWatcher = class {
4486
4530
  }
4487
4531
  };
4488
4532
 
4533
+ // src/services/conversations/findSearchTarget.ts
4534
+ var SNIPPET_CONTEXT = 60;
4535
+ var MAX_MATCH_INDEXES = 1e3;
4536
+ function buildSnippet(source, matchStart, matchLength) {
4537
+ const start = Math.max(0, matchStart - SNIPPET_CONTEXT);
4538
+ const end = Math.min(source.length, matchStart + matchLength + SNIPPET_CONTEXT);
4539
+ const prefix = start > 0 ? "\u2026" : "";
4540
+ const suffix = end < source.length ? "\u2026" : "";
4541
+ return `${prefix}${source.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
4542
+ }
4543
+ function extendedBody(m) {
4544
+ const parts = [];
4545
+ if (m.isThinking && m.thinkingContent) parts.push(m.thinkingContent);
4546
+ for (const b of m.metadata?.toolUseBlocks ?? []) {
4547
+ if (b.input !== void 0) parts.push(JSON.stringify(b.input));
4548
+ }
4549
+ for (const r of m.metadata?.toolResults ?? []) {
4550
+ if (r.content !== void 0) parts.push(JSON.stringify(r.content));
4551
+ }
4552
+ return parts.join("\n");
4553
+ }
4554
+ function collectMatches(messages, needle, body) {
4555
+ const indexes = [];
4556
+ for (let i = 0; i < messages.length; i++) {
4557
+ if (body(messages[i]).toLowerCase().includes(needle)) indexes.push(i);
4558
+ }
4559
+ return indexes;
4560
+ }
4561
+ function findSearchTarget(messages, query) {
4562
+ const needle = query.toLowerCase();
4563
+ let body = (m) => m.text ?? "";
4564
+ let matches = collectMatches(messages, needle, body);
4565
+ if (matches.length === 0) {
4566
+ body = extendedBody;
4567
+ matches = collectMatches(messages, needle, body);
4568
+ }
4569
+ if (matches.length === 0) return null;
4570
+ const anchorIndex = matches[matches.length - 1];
4571
+ const anchorBody = body(messages[anchorIndex]);
4572
+ const at = anchorBody.toLowerCase().indexOf(needle);
4573
+ return {
4574
+ messageIndex: anchorIndex,
4575
+ uuid: messages[anchorIndex].uuid ?? null,
4576
+ snippet: buildSnippet(anchorBody, at, query.length),
4577
+ matchIndexes: matches.slice(-MAX_MATCH_INDEXES),
4578
+ totalMatches: matches.length
4579
+ };
4580
+ }
4581
+
4489
4582
  // src/services/conversations/pruneAgentConversations.ts
4490
4583
  var import_fs11 = require("fs");
4491
4584
  function pruneAgentConversations(cache) {
@@ -5101,6 +5194,7 @@ var StreamerServer = class {
5101
5194
  disableDb = false;
5102
5195
  browseRoot = null;
5103
5196
  publicUrl = null;
5197
+ browserCors;
5104
5198
  pairTokens = new PairTokenStore();
5105
5199
  exchangeAttempts = /* @__PURE__ */ new Map();
5106
5200
  sessionStartAttempts = /* @__PURE__ */ new Map();
@@ -5186,6 +5280,7 @@ var StreamerServer = class {
5186
5280
  this.log.warn(`Warning: ${result.error}`, { error: result.error });
5187
5281
  }
5188
5282
  }
5283
+ this.browserCors = config.browserCors ?? loadBrowserCors();
5189
5284
  this.sessionStore = new SessionStore();
5190
5285
  this.wsHub = new WSHub();
5191
5286
  this.fileWatcher = new ConversationWatcher({
@@ -5340,6 +5435,7 @@ var StreamerServer = class {
5340
5435
  rotateApiKey: () => this.rotateApiKey(),
5341
5436
  publicUrl: this.publicUrl,
5342
5437
  browseRoot: this.browseRoot,
5438
+ browserCors: this.browserCors,
5343
5439
  ptyManager: this.ptyManager,
5344
5440
  sessionStore: this.sessionStore,
5345
5441
  wsHub: this.wsHub,
@@ -5368,6 +5464,7 @@ var StreamerServer = class {
5368
5464
  handleConversationsCount: (url, res) => this.handleConversationsCount(url, res),
5369
5465
  handleGetConversation: (id, url, res, ifNoneMatch) => this.handleGetConversation(id, url, res, ifNoneMatch),
5370
5466
  handleSearch: (url, res) => this.handleSearch(url, res),
5467
+ handleSearchTarget: (id, req, res) => this.handleSearchTarget(id, req, res),
5371
5468
  handleListProjects: (url, res) => handleListProjects(url, res),
5372
5469
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
5373
5470
  handlePairStart: (res) => this.handlePairStart(res),
@@ -6268,7 +6365,7 @@ var StreamerServer = class {
6268
6365
  messageCount: etagSource.messageCount,
6269
6366
  timestamp: etagSource.timestamp
6270
6367
  });
6271
- const isFirstPage = !url.searchParams.has("before_index");
6368
+ const isFirstPage = !url.searchParams.has("before_index") && !url.searchParams.has("anchor_index") && !url.searchParams.has("after_index");
6272
6369
  if (isFirstPage && ifNoneMatch && ifNoneMatch === etag) {
6273
6370
  res.writeHead(304, { ETag: etag, "Access-Control-Expose-Headers": "ETag" });
6274
6371
  res.end();
@@ -6276,29 +6373,53 @@ var StreamerServer = class {
6276
6373
  }
6277
6374
  const filtered = conversation.messages;
6278
6375
  const total = filtered.length;
6279
- const usePaging = url.searchParams.has("msg_limit") || url.searchParams.has("before_index");
6376
+ const hasAnchor = url.searchParams.has("anchor_index");
6377
+ const hasAfter = url.searchParams.has("after_index");
6378
+ const usePaging = url.searchParams.has("msg_limit") || url.searchParams.has("before_index") || hasAnchor || hasAfter;
6280
6379
  let slice = filtered;
6281
6380
  let fromIdx = 0;
6282
6381
  let messagePagination;
6283
6382
  if (usePaging) {
6284
6383
  const limit = Math.min(Math.max(intParam(url, "msg_limit", 80), 1), 500);
6285
6384
  let beforeIndex = total;
6385
+ let scanLimit = limit;
6386
+ let anchorIndex = null;
6387
+ let newerPaging = false;
6286
6388
  if (url.searchParams.has("before_index")) {
6287
6389
  beforeIndex = intParam(url, "before_index", total);
6288
6390
  beforeIndex = Math.min(Math.max(beforeIndex, 0), total);
6391
+ } else if (hasAfter) {
6392
+ const from = Math.min(Math.max(intParam(url, "after_index", 0), 0), total);
6393
+ beforeIndex = Math.min(total, from + limit);
6394
+ scanLimit = beforeIndex - from;
6395
+ newerPaging = true;
6396
+ } else if (hasAnchor) {
6397
+ anchorIndex = Math.min(
6398
+ Math.max(intParam(url, "anchor_index", 0), 0),
6399
+ Math.max(0, total - 1)
6400
+ );
6401
+ const from = Math.max(0, anchorIndex - Math.floor(limit / 2));
6402
+ beforeIndex = Math.min(total, from + limit);
6403
+ newerPaging = true;
6289
6404
  }
6290
6405
  const pagedScanner = this.scannerReady ? await this.getScanner(true) : null;
6291
- const page = pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit }) : null;
6292
- const start = page?.fromIndex ?? Math.max(0, beforeIndex - limit);
6406
+ const page = scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null;
6407
+ const start = page?.fromIndex ?? Math.max(0, beforeIndex - scanLimit);
6293
6408
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
6294
6409
  fromIdx = start;
6410
+ const effectiveTotal = page?.total ?? total;
6295
6411
  messagePagination = {
6296
- total: page?.total ?? total,
6412
+ total: effectiveTotal,
6297
6413
  before_index: beforeIndex,
6298
6414
  from_index: start,
6299
6415
  has_more_older: start > 0,
6300
6416
  next_before_index: start > 0 ? start : null
6301
6417
  };
6418
+ if (anchorIndex != null) messagePagination.anchor_index = anchorIndex;
6419
+ if (newerPaging) {
6420
+ messagePagination.has_more_newer = beforeIndex < effectiveTotal;
6421
+ messagePagination.next_after_index = beforeIndex < effectiveTotal ? beforeIndex : null;
6422
+ }
6302
6423
  }
6303
6424
  const messagesPayload = slice.map((m, localIdx) => {
6304
6425
  const content = [];
@@ -6373,6 +6494,65 @@ var StreamerServer = class {
6373
6494
  });
6374
6495
  res.end(JSON.stringify(body));
6375
6496
  }
6497
+ // Resolves an active search query to the message a client should anchor to
6498
+ // inside one conversation. Matching is body-only (text first, then
6499
+ // thinking/tool payloads) — a metadata-only search hit (project path, title)
6500
+ // has no scroll target and returns 404 search_target_not_found.
6501
+ //
6502
+ // Implements HTTP QUERY (RFC 10008): the search query travels in a JSON
6503
+ // request body instead of a URL query param — QUERY is safe + idempotent +
6504
+ // cacheable like GET, but (like POST) can carry a body, which fits this
6505
+ // endpoint's single-string input exactly. `Accept-Query` advertises the
6506
+ // supported request media type per the spec.
6507
+ async handleSearchTarget(id, req, res) {
6508
+ const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim();
6509
+ if (contentType && contentType !== "application/json") {
6510
+ res.setHeader("Accept-Query", "application/json");
6511
+ json(res, 415, {
6512
+ error: "Unsupported Content-Type; expected application/json",
6513
+ code: "unsupported_media_type"
6514
+ });
6515
+ return;
6516
+ }
6517
+ let body;
6518
+ try {
6519
+ body = await readBody(req);
6520
+ } catch {
6521
+ res.setHeader("Accept-Query", "application/json");
6522
+ json(res, 422, { error: "Malformed JSON body", code: "invalid_query" });
6523
+ return;
6524
+ }
6525
+ const q = typeof body?.q === "string" ? body.q.trim() : "";
6526
+ if (!q) {
6527
+ res.setHeader("Accept-Query", "application/json");
6528
+ json(res, 422, { error: "Missing or empty query field: q", code: "invalid_query" });
6529
+ return;
6530
+ }
6531
+ if (q.length > 256) {
6532
+ res.setHeader("Accept-Query", "application/json");
6533
+ json(res, 422, { error: "Query too long (max 256 characters)", code: "invalid_query" });
6534
+ return;
6535
+ }
6536
+ const conversation = await this.findConversationByUuid(id);
6537
+ if (!conversation) {
6538
+ json(res, 404, { error: "Conversation not found", code: "not_found" });
6539
+ return;
6540
+ }
6541
+ const target = findSearchTarget(conversation.messages, q);
6542
+ if (!target) {
6543
+ json(res, 404, { error: "No message body matches query", code: "search_target_not_found" });
6544
+ return;
6545
+ }
6546
+ res.setHeader("Accept-Query", "application/json");
6547
+ json(res, 200, {
6548
+ query: q,
6549
+ message_index: target.messageIndex,
6550
+ uuid: target.uuid,
6551
+ snippet: target.snippet,
6552
+ match_indexes: target.matchIndexes,
6553
+ total_matches: target.totalMatches
6554
+ });
6555
+ }
6376
6556
  async handleSearch(url, res) {
6377
6557
  const q = url.searchParams.get("q") ?? "";
6378
6558
  if (!q) {
@@ -6392,7 +6572,11 @@ var StreamerServer = class {
6392
6572
  scanner
6393
6573
  );
6394
6574
  const adapted = results.map((r) => ({
6395
- id: r.meta.id.split("/").pop()?.replace(/\.jsonl$/, "") || r.meta.id,
6575
+ // Use sessionId so the id matches /api/conversations and resolves via
6576
+ // findConversationByUuid — a client can round-trip a search result into
6577
+ // GET /api/conversations/:id or the search-target QUERY. The old
6578
+ // filename-stem derivation produced an id no other endpoint recognized.
6579
+ id: r.meta.sessionId || r.meta.id,
6396
6580
  title: r.meta.projectName,
6397
6581
  sessionName: r.meta.sessionName || void 0,
6398
6582
  filePath: r.meta.filePath,