@threadbase-sh/streamer 1.26.0 → 1.27.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
@@ -747,6 +747,7 @@ type ApiDeps = {
747
747
  handleConversationsCount: (url: URL, res: ServerResponse) => Promise<void>;
748
748
  handleGetConversation: (id: string, url: URL, res: ServerResponse, ifNoneMatch?: string) => Promise<void>;
749
749
  handleSearch: (url: URL, res: ServerResponse) => Promise<void>;
750
+ handleSearchTarget: (id: string, req: IncomingMessage, res: ServerResponse) => Promise<void>;
750
751
  handleListProjects: (url: URL, res: ServerResponse) => void;
751
752
  handleGetPopularProjects: (url: URL, res: ServerResponse) => void;
752
753
  handlePairStart: (res: ServerResponse) => void;
@@ -970,6 +971,7 @@ declare class StreamerServer {
970
971
  private findConversationByUuid;
971
972
  private isConversationSnapshotStale;
972
973
  private handleGetConversation;
974
+ private handleSearchTarget;
973
975
  private handleSearch;
974
976
  private handleListSessions;
975
977
  private handleGetSession;
package/dist/index.d.ts CHANGED
@@ -747,6 +747,7 @@ type ApiDeps = {
747
747
  handleConversationsCount: (url: URL, res: ServerResponse) => Promise<void>;
748
748
  handleGetConversation: (id: string, url: URL, res: ServerResponse, ifNoneMatch?: string) => Promise<void>;
749
749
  handleSearch: (url: URL, res: ServerResponse) => Promise<void>;
750
+ handleSearchTarget: (id: string, req: IncomingMessage, res: ServerResponse) => Promise<void>;
750
751
  handleListProjects: (url: URL, res: ServerResponse) => void;
751
752
  handleGetPopularProjects: (url: URL, res: ServerResponse) => void;
752
753
  handlePairStart: (res: ServerResponse) => void;
@@ -970,6 +971,7 @@ declare class StreamerServer {
970
971
  private findConversationByUuid;
971
972
  private isConversationSnapshotStale;
972
973
  private handleGetConversation;
974
+ private handleSearchTarget;
973
975
  private handleSearch;
974
976
  private handleListSessions;
975
977
  private handleGetSession;
package/dist/index.js CHANGED
@@ -673,6 +673,9 @@ var CODEX_CLI_PROVIDER = "codex-cli";
673
673
  function isProviderName(value) {
674
674
  return value === CLAUDE_CODE_PROVIDER || value === CODEX_CLI_PROVIDER;
675
675
  }
676
+ function coerceProviderForRunner(value) {
677
+ return isProviderName(value) ? value : CLAUDE_CODE_PROVIDER;
678
+ }
676
679
  function isProviderResumable(_provider, availabilityResumable) {
677
680
  return availabilityResumable;
678
681
  }
@@ -2615,17 +2618,17 @@ var corsMiddleware = (configValue) => {
2615
2618
  const raw = c.env.outgoing;
2616
2619
  raw.setHeader("Access-Control-Allow-Origin", allowedOrigin);
2617
2620
  raw.setHeader("Vary", "Origin");
2618
- raw.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2621
+ raw.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, QUERY, OPTIONS");
2619
2622
  raw.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
2620
- raw.setHeader("Access-Control-Expose-Headers", "ETag");
2623
+ raw.setHeader("Access-Control-Expose-Headers", "ETag, Accept-Query");
2621
2624
  c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
2622
2625
  c.res.headers.set("Vary", "Origin");
2623
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
2626
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, QUERY, OPTIONS");
2624
2627
  c.res.headers.set(
2625
2628
  "Access-Control-Allow-Headers",
2626
2629
  "Authorization, Content-Type, If-None-Match"
2627
2630
  );
2628
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
2631
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag, Accept-Query");
2629
2632
  }
2630
2633
  if (c.req.method === "OPTIONS") {
2631
2634
  return c.newResponse(null, allowedOrigin ? 204 : 403);
@@ -2669,6 +2672,11 @@ var createConversationRoutes = (deps) => {
2669
2672
  await deps.handleConversationsCount(url, c.env.outgoing);
2670
2673
  return alreadyHandled2();
2671
2674
  });
2675
+ app.on("QUERY", "/:id{.+}/search-target", async (c) => {
2676
+ const id = c.req.param("id");
2677
+ await deps.handleSearchTarget(id, c.env.incoming, c.env.outgoing);
2678
+ return alreadyHandled2();
2679
+ });
2672
2680
  app.get("/:id{.+}", async (c) => {
2673
2681
  const id = c.req.param("id");
2674
2682
  const url = new URL(c.req.url);
@@ -4486,6 +4494,55 @@ var ConversationWatcher = class {
4486
4494
  }
4487
4495
  };
4488
4496
 
4497
+ // src/services/conversations/findSearchTarget.ts
4498
+ var SNIPPET_CONTEXT = 60;
4499
+ var MAX_MATCH_INDEXES = 1e3;
4500
+ function buildSnippet(source, matchStart, matchLength) {
4501
+ const start = Math.max(0, matchStart - SNIPPET_CONTEXT);
4502
+ const end = Math.min(source.length, matchStart + matchLength + SNIPPET_CONTEXT);
4503
+ const prefix = start > 0 ? "\u2026" : "";
4504
+ const suffix = end < source.length ? "\u2026" : "";
4505
+ return `${prefix}${source.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
4506
+ }
4507
+ function extendedBody(m) {
4508
+ const parts = [];
4509
+ if (m.isThinking && m.thinkingContent) parts.push(m.thinkingContent);
4510
+ for (const b of m.metadata?.toolUseBlocks ?? []) {
4511
+ if (b.input !== void 0) parts.push(JSON.stringify(b.input));
4512
+ }
4513
+ for (const r of m.metadata?.toolResults ?? []) {
4514
+ if (r.content !== void 0) parts.push(JSON.stringify(r.content));
4515
+ }
4516
+ return parts.join("\n");
4517
+ }
4518
+ function collectMatches(messages, needle, body) {
4519
+ const indexes = [];
4520
+ for (let i = 0; i < messages.length; i++) {
4521
+ if (body(messages[i]).toLowerCase().includes(needle)) indexes.push(i);
4522
+ }
4523
+ return indexes;
4524
+ }
4525
+ function findSearchTarget(messages, query) {
4526
+ const needle = query.toLowerCase();
4527
+ let body = (m) => m.text ?? "";
4528
+ let matches = collectMatches(messages, needle, body);
4529
+ if (matches.length === 0) {
4530
+ body = extendedBody;
4531
+ matches = collectMatches(messages, needle, body);
4532
+ }
4533
+ if (matches.length === 0) return null;
4534
+ const anchorIndex = matches[matches.length - 1];
4535
+ const anchorBody = body(messages[anchorIndex]);
4536
+ const at = anchorBody.toLowerCase().indexOf(needle);
4537
+ return {
4538
+ messageIndex: anchorIndex,
4539
+ uuid: messages[anchorIndex].uuid ?? null,
4540
+ snippet: buildSnippet(anchorBody, at, query.length),
4541
+ matchIndexes: matches.slice(-MAX_MATCH_INDEXES),
4542
+ totalMatches: matches.length
4543
+ };
4544
+ }
4545
+
4489
4546
  // src/services/conversations/pruneAgentConversations.ts
4490
4547
  import { existsSync as existsSync6 } from "fs";
4491
4548
  function pruneAgentConversations(cache) {
@@ -5371,6 +5428,7 @@ var StreamerServer = class {
5371
5428
  handleConversationsCount: (url, res) => this.handleConversationsCount(url, res),
5372
5429
  handleGetConversation: (id, url, res, ifNoneMatch) => this.handleGetConversation(id, url, res, ifNoneMatch),
5373
5430
  handleSearch: (url, res) => this.handleSearch(url, res),
5431
+ handleSearchTarget: (id, req, res) => this.handleSearchTarget(id, req, res),
5374
5432
  handleListProjects: (url, res) => handleListProjects(url, res),
5375
5433
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
5376
5434
  handlePairStart: (res) => this.handlePairStart(res),
@@ -6271,7 +6329,7 @@ var StreamerServer = class {
6271
6329
  messageCount: etagSource.messageCount,
6272
6330
  timestamp: etagSource.timestamp
6273
6331
  });
6274
- const isFirstPage = !url.searchParams.has("before_index");
6332
+ const isFirstPage = !url.searchParams.has("before_index") && !url.searchParams.has("anchor_index") && !url.searchParams.has("after_index");
6275
6333
  if (isFirstPage && ifNoneMatch && ifNoneMatch === etag) {
6276
6334
  res.writeHead(304, { ETag: etag, "Access-Control-Expose-Headers": "ETag" });
6277
6335
  res.end();
@@ -6279,29 +6337,53 @@ var StreamerServer = class {
6279
6337
  }
6280
6338
  const filtered = conversation.messages;
6281
6339
  const total = filtered.length;
6282
- const usePaging = url.searchParams.has("msg_limit") || url.searchParams.has("before_index");
6340
+ const hasAnchor = url.searchParams.has("anchor_index");
6341
+ const hasAfter = url.searchParams.has("after_index");
6342
+ const usePaging = url.searchParams.has("msg_limit") || url.searchParams.has("before_index") || hasAnchor || hasAfter;
6283
6343
  let slice = filtered;
6284
6344
  let fromIdx = 0;
6285
6345
  let messagePagination;
6286
6346
  if (usePaging) {
6287
6347
  const limit = Math.min(Math.max(intParam(url, "msg_limit", 80), 1), 500);
6288
6348
  let beforeIndex = total;
6349
+ let scanLimit = limit;
6350
+ let anchorIndex = null;
6351
+ let newerPaging = false;
6289
6352
  if (url.searchParams.has("before_index")) {
6290
6353
  beforeIndex = intParam(url, "before_index", total);
6291
6354
  beforeIndex = Math.min(Math.max(beforeIndex, 0), total);
6355
+ } else if (hasAfter) {
6356
+ const from = Math.min(Math.max(intParam(url, "after_index", 0), 0), total);
6357
+ beforeIndex = Math.min(total, from + limit);
6358
+ scanLimit = beforeIndex - from;
6359
+ newerPaging = true;
6360
+ } else if (hasAnchor) {
6361
+ anchorIndex = Math.min(
6362
+ Math.max(intParam(url, "anchor_index", 0), 0),
6363
+ Math.max(0, total - 1)
6364
+ );
6365
+ const from = Math.max(0, anchorIndex - Math.floor(limit / 2));
6366
+ beforeIndex = Math.min(total, from + limit);
6367
+ newerPaging = true;
6292
6368
  }
6293
6369
  const pagedScanner = this.scannerReady ? await this.getScanner(true) : null;
6294
- const page = pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit }) : null;
6295
- const start = page?.fromIndex ?? Math.max(0, beforeIndex - limit);
6370
+ const page = scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null;
6371
+ const start = page?.fromIndex ?? Math.max(0, beforeIndex - scanLimit);
6296
6372
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
6297
6373
  fromIdx = start;
6374
+ const effectiveTotal = page?.total ?? total;
6298
6375
  messagePagination = {
6299
- total: page?.total ?? total,
6376
+ total: effectiveTotal,
6300
6377
  before_index: beforeIndex,
6301
6378
  from_index: start,
6302
6379
  has_more_older: start > 0,
6303
6380
  next_before_index: start > 0 ? start : null
6304
6381
  };
6382
+ if (anchorIndex != null) messagePagination.anchor_index = anchorIndex;
6383
+ if (newerPaging) {
6384
+ messagePagination.has_more_newer = beforeIndex < effectiveTotal;
6385
+ messagePagination.next_after_index = beforeIndex < effectiveTotal ? beforeIndex : null;
6386
+ }
6305
6387
  }
6306
6388
  const messagesPayload = slice.map((m, localIdx) => {
6307
6389
  const content = [];
@@ -6341,7 +6423,7 @@ var StreamerServer = class {
6341
6423
  });
6342
6424
  const conv = conversation;
6343
6425
  const cachedConvMeta = this.cache?.getMetaById(id);
6344
- const convProvider = conv.provider ?? cachedConvMeta?.provider ?? CLAUDE_CODE_PROVIDER;
6426
+ const convProvider = coerceProviderForRunner(conv.provider ?? cachedConvMeta?.provider);
6345
6427
  const availability = classifyResumability(conv.projectPath);
6346
6428
  const body = {
6347
6429
  meta: {
@@ -6376,6 +6458,65 @@ var StreamerServer = class {
6376
6458
  });
6377
6459
  res.end(JSON.stringify(body));
6378
6460
  }
6461
+ // Resolves an active search query to the message a client should anchor to
6462
+ // inside one conversation. Matching is body-only (text first, then
6463
+ // thinking/tool payloads) — a metadata-only search hit (project path, title)
6464
+ // has no scroll target and returns 404 search_target_not_found.
6465
+ //
6466
+ // Implements HTTP QUERY (RFC 10008): the search query travels in a JSON
6467
+ // request body instead of a URL query param — QUERY is safe + idempotent +
6468
+ // cacheable like GET, but (like POST) can carry a body, which fits this
6469
+ // endpoint's single-string input exactly. `Accept-Query` advertises the
6470
+ // supported request media type per the spec.
6471
+ async handleSearchTarget(id, req, res) {
6472
+ const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim();
6473
+ if (contentType && contentType !== "application/json") {
6474
+ res.setHeader("Accept-Query", "application/json");
6475
+ json(res, 415, {
6476
+ error: "Unsupported Content-Type; expected application/json",
6477
+ code: "unsupported_media_type"
6478
+ });
6479
+ return;
6480
+ }
6481
+ let body;
6482
+ try {
6483
+ body = await readBody(req);
6484
+ } catch {
6485
+ res.setHeader("Accept-Query", "application/json");
6486
+ json(res, 422, { error: "Malformed JSON body", code: "invalid_query" });
6487
+ return;
6488
+ }
6489
+ const q = typeof body?.q === "string" ? body.q.trim() : "";
6490
+ if (!q) {
6491
+ res.setHeader("Accept-Query", "application/json");
6492
+ json(res, 422, { error: "Missing or empty query field: q", code: "invalid_query" });
6493
+ return;
6494
+ }
6495
+ if (q.length > 256) {
6496
+ res.setHeader("Accept-Query", "application/json");
6497
+ json(res, 422, { error: "Query too long (max 256 characters)", code: "invalid_query" });
6498
+ return;
6499
+ }
6500
+ const conversation = await this.findConversationByUuid(id);
6501
+ if (!conversation) {
6502
+ json(res, 404, { error: "Conversation not found", code: "not_found" });
6503
+ return;
6504
+ }
6505
+ const target = findSearchTarget(conversation.messages, q);
6506
+ if (!target) {
6507
+ json(res, 404, { error: "No message body matches query", code: "search_target_not_found" });
6508
+ return;
6509
+ }
6510
+ res.setHeader("Accept-Query", "application/json");
6511
+ json(res, 200, {
6512
+ query: q,
6513
+ message_index: target.messageIndex,
6514
+ uuid: target.uuid,
6515
+ snippet: target.snippet,
6516
+ match_indexes: target.matchIndexes,
6517
+ total_matches: target.totalMatches
6518
+ });
6519
+ }
6379
6520
  async handleSearch(url, res) {
6380
6521
  const q = url.searchParams.get("q") ?? "";
6381
6522
  if (!q) {
@@ -6395,7 +6536,11 @@ var StreamerServer = class {
6395
6536
  scanner
6396
6537
  );
6397
6538
  const adapted = results.map((r) => ({
6398
- id: r.meta.id.split("/").pop()?.replace(/\.jsonl$/, "") || r.meta.id,
6539
+ // Use sessionId so the id matches /api/conversations and resolves via
6540
+ // findConversationByUuid — a client can round-trip a search result into
6541
+ // GET /api/conversations/:id or the search-target QUERY. The old
6542
+ // filename-stem derivation produced an id no other endpoint recognized.
6543
+ id: r.meta.sessionId || r.meta.id,
6399
6544
  title: r.meta.projectName,
6400
6545
  sessionName: r.meta.sessionName || void 0,
6401
6546
  filePath: r.meta.filePath,
@@ -6492,7 +6637,7 @@ var StreamerServer = class {
6492
6637
  return;
6493
6638
  }
6494
6639
  const cachedConvMeta = this.cache?.getMetaById(sessionId);
6495
- const provider = conv?.provider ?? cachedConvMeta?.provider ?? CLAUDE_CODE_PROVIDER;
6640
+ const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
6496
6641
  const session = await this.ptyManager.start(sessionId, {
6497
6642
  provider,
6498
6643
  projectPath,