@threadbase-sh/streamer 1.26.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/cli.cjs CHANGED
@@ -135843,17 +135843,17 @@ var corsMiddleware = (configValue) => {
135843
135843
  const raw2 = c.env.outgoing;
135844
135844
  raw2.setHeader("Access-Control-Allow-Origin", allowedOrigin);
135845
135845
  raw2.setHeader("Vary", "Origin");
135846
- raw2.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
135846
+ raw2.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, QUERY, OPTIONS");
135847
135847
  raw2.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
135848
- raw2.setHeader("Access-Control-Expose-Headers", "ETag");
135848
+ raw2.setHeader("Access-Control-Expose-Headers", "ETag, Accept-Query");
135849
135849
  c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
135850
135850
  c.res.headers.set("Vary", "Origin");
135851
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
135851
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, QUERY, OPTIONS");
135852
135852
  c.res.headers.set(
135853
135853
  "Access-Control-Allow-Headers",
135854
135854
  "Authorization, Content-Type, If-None-Match"
135855
135855
  );
135856
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
135856
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag, Accept-Query");
135857
135857
  }
135858
135858
  if (c.req.method === "OPTIONS") {
135859
135859
  return c.newResponse(null, allowedOrigin ? 204 : 403);
@@ -135895,6 +135895,11 @@ var createConversationRoutes = (deps) => {
135895
135895
  await deps.handleConversationsCount(url2, c.env.outgoing);
135896
135896
  return alreadyHandled2();
135897
135897
  });
135898
+ app.on("QUERY", "/:id{.+}/search-target", async (c) => {
135899
+ const id = c.req.param("id");
135900
+ await deps.handleSearchTarget(id, c.env.incoming, c.env.outgoing);
135901
+ return alreadyHandled2();
135902
+ });
135898
135903
  app.get("/:id{.+}", async (c) => {
135899
135904
  const id = c.req.param("id");
135900
135905
  const url2 = new URL(c.req.url);
@@ -141350,6 +141355,55 @@ var ConversationWatcher = class {
141350
141355
  }
141351
141356
  };
141352
141357
 
141358
+ // src/services/conversations/findSearchTarget.ts
141359
+ var SNIPPET_CONTEXT = 60;
141360
+ var MAX_MATCH_INDEXES = 1e3;
141361
+ function buildSnippet(source, matchStart, matchLength) {
141362
+ const start = Math.max(0, matchStart - SNIPPET_CONTEXT);
141363
+ const end = Math.min(source.length, matchStart + matchLength + SNIPPET_CONTEXT);
141364
+ const prefix = start > 0 ? "\u2026" : "";
141365
+ const suffix = end < source.length ? "\u2026" : "";
141366
+ return `${prefix}${source.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
141367
+ }
141368
+ function extendedBody(m2) {
141369
+ const parts = [];
141370
+ if (m2.isThinking && m2.thinkingContent) parts.push(m2.thinkingContent);
141371
+ for (const b2 of m2.metadata?.toolUseBlocks ?? []) {
141372
+ if (b2.input !== void 0) parts.push(JSON.stringify(b2.input));
141373
+ }
141374
+ for (const r of m2.metadata?.toolResults ?? []) {
141375
+ if (r.content !== void 0) parts.push(JSON.stringify(r.content));
141376
+ }
141377
+ return parts.join("\n");
141378
+ }
141379
+ function collectMatches(messages, needle, body) {
141380
+ const indexes = [];
141381
+ for (let i = 0; i < messages.length; i++) {
141382
+ if (body(messages[i]).toLowerCase().includes(needle)) indexes.push(i);
141383
+ }
141384
+ return indexes;
141385
+ }
141386
+ function findSearchTarget(messages, query) {
141387
+ const needle = query.toLowerCase();
141388
+ let body = (m2) => m2.text ?? "";
141389
+ let matches = collectMatches(messages, needle, body);
141390
+ if (matches.length === 0) {
141391
+ body = extendedBody;
141392
+ matches = collectMatches(messages, needle, body);
141393
+ }
141394
+ if (matches.length === 0) return null;
141395
+ const anchorIndex = matches[matches.length - 1];
141396
+ const anchorBody = body(messages[anchorIndex]);
141397
+ const at2 = anchorBody.toLowerCase().indexOf(needle);
141398
+ return {
141399
+ messageIndex: anchorIndex,
141400
+ uuid: messages[anchorIndex].uuid ?? null,
141401
+ snippet: buildSnippet(anchorBody, at2, query.length),
141402
+ matchIndexes: matches.slice(-MAX_MATCH_INDEXES),
141403
+ totalMatches: matches.length
141404
+ };
141405
+ }
141406
+
141353
141407
  // src/services/conversations/pruneAgentConversations.ts
141354
141408
  var import_fs23 = require("fs");
141355
141409
  function pruneAgentConversations(cache) {
@@ -142473,6 +142527,7 @@ var StreamerServer = class {
142473
142527
  handleConversationsCount: (url2, res) => this.handleConversationsCount(url2, res),
142474
142528
  handleGetConversation: (id, url2, res, ifNoneMatch) => this.handleGetConversation(id, url2, res, ifNoneMatch),
142475
142529
  handleSearch: (url2, res) => this.handleSearch(url2, res),
142530
+ handleSearchTarget: (id, req, res) => this.handleSearchTarget(id, req, res),
142476
142531
  handleListProjects: (url2, res) => handleListProjects(url2, res),
142477
142532
  handleGetPopularProjects: (url2, res) => this.handleGetPopularProjects(url2, res),
142478
142533
  handlePairStart: (res) => this.handlePairStart(res),
@@ -143373,7 +143428,7 @@ var StreamerServer = class {
143373
143428
  messageCount: etagSource.messageCount,
143374
143429
  timestamp: etagSource.timestamp
143375
143430
  });
143376
- const isFirstPage = !url2.searchParams.has("before_index");
143431
+ const isFirstPage = !url2.searchParams.has("before_index") && !url2.searchParams.has("anchor_index") && !url2.searchParams.has("after_index");
143377
143432
  if (isFirstPage && ifNoneMatch && ifNoneMatch === etag) {
143378
143433
  res.writeHead(304, { ETag: etag, "Access-Control-Expose-Headers": "ETag" });
143379
143434
  res.end();
@@ -143381,29 +143436,53 @@ var StreamerServer = class {
143381
143436
  }
143382
143437
  const filtered = conversation.messages;
143383
143438
  const total = filtered.length;
143384
- const usePaging = url2.searchParams.has("msg_limit") || url2.searchParams.has("before_index");
143439
+ const hasAnchor = url2.searchParams.has("anchor_index");
143440
+ const hasAfter = url2.searchParams.has("after_index");
143441
+ const usePaging = url2.searchParams.has("msg_limit") || url2.searchParams.has("before_index") || hasAnchor || hasAfter;
143385
143442
  let slice = filtered;
143386
143443
  let fromIdx = 0;
143387
143444
  let messagePagination;
143388
143445
  if (usePaging) {
143389
143446
  const limit = Math.min(Math.max(intParam(url2, "msg_limit", 80), 1), 500);
143390
143447
  let beforeIndex = total;
143448
+ let scanLimit = limit;
143449
+ let anchorIndex = null;
143450
+ let newerPaging = false;
143391
143451
  if (url2.searchParams.has("before_index")) {
143392
143452
  beforeIndex = intParam(url2, "before_index", total);
143393
143453
  beforeIndex = Math.min(Math.max(beforeIndex, 0), total);
143454
+ } else if (hasAfter) {
143455
+ const from = Math.min(Math.max(intParam(url2, "after_index", 0), 0), total);
143456
+ beforeIndex = Math.min(total, from + limit);
143457
+ scanLimit = beforeIndex - from;
143458
+ newerPaging = true;
143459
+ } else if (hasAnchor) {
143460
+ anchorIndex = Math.min(
143461
+ Math.max(intParam(url2, "anchor_index", 0), 0),
143462
+ Math.max(0, total - 1)
143463
+ );
143464
+ const from = Math.max(0, anchorIndex - Math.floor(limit / 2));
143465
+ beforeIndex = Math.min(total, from + limit);
143466
+ newerPaging = true;
143394
143467
  }
143395
143468
  const pagedScanner = this.scannerReady ? await this.getScanner(true) : null;
143396
- const page = pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit }) : null;
143397
- const start = page?.fromIndex ?? Math.max(0, beforeIndex - limit);
143469
+ const page = scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null;
143470
+ const start = page?.fromIndex ?? Math.max(0, beforeIndex - scanLimit);
143398
143471
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
143399
143472
  fromIdx = start;
143473
+ const effectiveTotal = page?.total ?? total;
143400
143474
  messagePagination = {
143401
- total: page?.total ?? total,
143475
+ total: effectiveTotal,
143402
143476
  before_index: beforeIndex,
143403
143477
  from_index: start,
143404
143478
  has_more_older: start > 0,
143405
143479
  next_before_index: start > 0 ? start : null
143406
143480
  };
143481
+ if (anchorIndex != null) messagePagination.anchor_index = anchorIndex;
143482
+ if (newerPaging) {
143483
+ messagePagination.has_more_newer = beforeIndex < effectiveTotal;
143484
+ messagePagination.next_after_index = beforeIndex < effectiveTotal ? beforeIndex : null;
143485
+ }
143407
143486
  }
143408
143487
  const messagesPayload = slice.map((m2, localIdx) => {
143409
143488
  const content = [];
@@ -143478,6 +143557,65 @@ var StreamerServer = class {
143478
143557
  });
143479
143558
  res.end(JSON.stringify(body));
143480
143559
  }
143560
+ // Resolves an active search query to the message a client should anchor to
143561
+ // inside one conversation. Matching is body-only (text first, then
143562
+ // thinking/tool payloads) — a metadata-only search hit (project path, title)
143563
+ // has no scroll target and returns 404 search_target_not_found.
143564
+ //
143565
+ // Implements HTTP QUERY (RFC 10008): the search query travels in a JSON
143566
+ // request body instead of a URL query param — QUERY is safe + idempotent +
143567
+ // cacheable like GET, but (like POST) can carry a body, which fits this
143568
+ // endpoint's single-string input exactly. `Accept-Query` advertises the
143569
+ // supported request media type per the spec.
143570
+ async handleSearchTarget(id, req, res) {
143571
+ const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim();
143572
+ if (contentType && contentType !== "application/json") {
143573
+ res.setHeader("Accept-Query", "application/json");
143574
+ json2(res, 415, {
143575
+ error: "Unsupported Content-Type; expected application/json",
143576
+ code: "unsupported_media_type"
143577
+ });
143578
+ return;
143579
+ }
143580
+ let body;
143581
+ try {
143582
+ body = await readBody(req);
143583
+ } catch {
143584
+ res.setHeader("Accept-Query", "application/json");
143585
+ json2(res, 422, { error: "Malformed JSON body", code: "invalid_query" });
143586
+ return;
143587
+ }
143588
+ const q2 = typeof body?.q === "string" ? body.q.trim() : "";
143589
+ if (!q2) {
143590
+ res.setHeader("Accept-Query", "application/json");
143591
+ json2(res, 422, { error: "Missing or empty query field: q", code: "invalid_query" });
143592
+ return;
143593
+ }
143594
+ if (q2.length > 256) {
143595
+ res.setHeader("Accept-Query", "application/json");
143596
+ json2(res, 422, { error: "Query too long (max 256 characters)", code: "invalid_query" });
143597
+ return;
143598
+ }
143599
+ const conversation = await this.findConversationByUuid(id);
143600
+ if (!conversation) {
143601
+ json2(res, 404, { error: "Conversation not found", code: "not_found" });
143602
+ return;
143603
+ }
143604
+ const target = findSearchTarget(conversation.messages, q2);
143605
+ if (!target) {
143606
+ json2(res, 404, { error: "No message body matches query", code: "search_target_not_found" });
143607
+ return;
143608
+ }
143609
+ res.setHeader("Accept-Query", "application/json");
143610
+ json2(res, 200, {
143611
+ query: q2,
143612
+ message_index: target.messageIndex,
143613
+ uuid: target.uuid,
143614
+ snippet: target.snippet,
143615
+ match_indexes: target.matchIndexes,
143616
+ total_matches: target.totalMatches
143617
+ });
143618
+ }
143481
143619
  async handleSearch(url2, res) {
143482
143620
  const q2 = url2.searchParams.get("q") ?? "";
143483
143621
  if (!q2) {
@@ -143497,7 +143635,11 @@ var StreamerServer = class {
143497
143635
  scanner
143498
143636
  );
143499
143637
  const adapted = results.map((r) => ({
143500
- id: r.meta.id.split("/").pop()?.replace(/\.jsonl$/, "") || r.meta.id,
143638
+ // Use sessionId so the id matches /api/conversations and resolves via
143639
+ // findConversationByUuid — a client can round-trip a search result into
143640
+ // GET /api/conversations/:id or the search-target QUERY. The old
143641
+ // filename-stem derivation produced an id no other endpoint recognized.
143642
+ id: r.meta.sessionId || r.meta.id,
143501
143643
  title: r.meta.projectName,
143502
143644
  sessionName: r.meta.sessionName || void 0,
143503
143645
  filePath: r.meta.filePath,