@threadbase-sh/streamer 1.27.3 → 1.28.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
@@ -2317,10 +2317,10 @@ async function readGitBranch(dir) {
2317
2317
  // src/server.ts
2318
2318
  var import_node_ws = require("@hono/node-ws");
2319
2319
  var import_client = require("@temporalio/client");
2320
- var import_scanner2 = require("@threadbase-sh/scanner");
2320
+ var import_scanner3 = require("@threadbase-sh/scanner");
2321
2321
  var import_events = require("events");
2322
2322
  var import_fs12 = require("fs");
2323
- var import_promises5 = require("fs/promises");
2323
+ var import_promises7 = require("fs/promises");
2324
2324
  var import_http = require("http");
2325
2325
  var import_os6 = require("os");
2326
2326
  var import_path13 = require("path");
@@ -3162,9 +3162,12 @@ async function createDirectory(parentAbsolutePath, name) {
3162
3162
  }
3163
3163
 
3164
3164
  // src/conversation-cache.ts
3165
+ var import_scanner2 = require("@threadbase-sh/scanner");
3165
3166
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
3166
3167
  var import_fs8 = require("fs");
3168
+ var import_promises3 = require("fs/promises");
3167
3169
  var import_path10 = require("path");
3170
+ var import_promises4 = require("timers/promises");
3168
3171
 
3169
3172
  // src/db/sqlite-migrate.ts
3170
3173
  var import_fs6 = require("fs");
@@ -3277,6 +3280,33 @@ function parseAgentEntrypointsEnv(raw) {
3277
3280
  return new Set(parts);
3278
3281
  }
3279
3282
 
3283
+ // src/utils/fileIdentity.ts
3284
+ var import_crypto5 = require("crypto");
3285
+ function fileIdentity(stat3, headBytes) {
3286
+ if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
3287
+ const head = headBytes ?? Buffer.alloc(0);
3288
+ return `fp:${(0, import_crypto5.createHash)("sha1").update(head).digest("hex")}`;
3289
+ }
3290
+ function splitCompleteLines(buf, baseOffset) {
3291
+ const spans = [];
3292
+ let lineStart = 0;
3293
+ let consumed = 0;
3294
+ for (let i = 0; i < buf.length; i++) {
3295
+ if (buf[i] !== 10) continue;
3296
+ const lineLen = i - lineStart;
3297
+ if (lineLen > 0) {
3298
+ spans.push({
3299
+ byteOffset: baseOffset + lineStart,
3300
+ byteLength: lineLen,
3301
+ text: buf.toString("utf-8", lineStart, i)
3302
+ });
3303
+ }
3304
+ lineStart = i + 1;
3305
+ consumed = lineStart;
3306
+ }
3307
+ return { spans, consumed };
3308
+ }
3309
+
3280
3310
  // src/conversation-cache.ts
3281
3311
  function shortProjectName(fullPath) {
3282
3312
  const parts = fullPath.split(/[/\\]/).filter(Boolean);
@@ -3325,6 +3355,14 @@ var ConversationCache = class _ConversationCache {
3325
3355
  tailSize;
3326
3356
  fileIndex = /* @__PURE__ */ new Map();
3327
3357
  fileIndexLoaded = false;
3358
+ // Per-file scanner parse state for the incremental offset-index writer. The
3359
+ // reducer is stateful across lines (pending tool_uses, latest timestamp), so
3360
+ // it must persist between watcher reads of the same file. Cleared on
3361
+ // truncation/backfill.
3362
+ indexParseState = /* @__PURE__ */ new Map();
3363
+ // Single-flight guard for backfillIndex — concurrent detail requests for the
3364
+ // same cold file await one walk, not N. Entry dropped on settle.
3365
+ backfillInFlight = /* @__PURE__ */ new Map();
3328
3366
  // Monotonically increasing counter for tail updated_at — guarantees strict
3329
3367
  // ordering even when multiple updateFromLine() calls land within the same ms.
3330
3368
  tailSeq = Date.now();
@@ -3488,6 +3526,42 @@ var ConversationCache = class _ConversationCache {
3488
3526
  GROUP BY project_path
3489
3527
  ORDER BY cnt DESC
3490
3528
  LIMIT ?`
3529
+ ),
3530
+ getFileState: db.prepare("SELECT * FROM conversation_file_state WHERE path = ?"),
3531
+ upsertFileState: db.prepare(
3532
+ `INSERT INTO conversation_file_state
3533
+ (path, identity, size, mtime_ms, byte_offset, last_message_index)
3534
+ VALUES (@path, @identity, @size, @mtime_ms, @byte_offset, @last_message_index)
3535
+ ON CONFLICT(path) DO UPDATE SET
3536
+ identity = excluded.identity,
3537
+ size = excluded.size,
3538
+ mtime_ms = excluded.mtime_ms,
3539
+ byte_offset = excluded.byte_offset,
3540
+ last_message_index = excluded.last_message_index`
3541
+ ),
3542
+ deleteFileState: db.prepare("DELETE FROM conversation_file_state WHERE path = ?"),
3543
+ deleteMessageIndex: db.prepare(
3544
+ "DELETE FROM conversation_message_index WHERE conversation_id = ?"
3545
+ ),
3546
+ insertMessageIndexRow: db.prepare(
3547
+ `INSERT INTO conversation_message_index
3548
+ (conversation_id, message_index, byte_offset, byte_length, uuid, role, ts)
3549
+ VALUES (@conversation_id, @message_index, @byte_offset, @byte_length, @uuid, @role, @ts)
3550
+ ON CONFLICT(conversation_id, message_index) DO UPDATE SET
3551
+ byte_offset = excluded.byte_offset,
3552
+ byte_length = excluded.byte_length,
3553
+ uuid = excluded.uuid,
3554
+ role = excluded.role,
3555
+ ts = excluded.ts`
3556
+ ),
3557
+ getMessageIndexWindow: db.prepare(
3558
+ `SELECT message_index, byte_offset, byte_length, uuid, role, ts
3559
+ FROM conversation_message_index
3560
+ WHERE conversation_id = ? AND message_index >= ? AND message_index < ?
3561
+ ORDER BY message_index ASC`
3562
+ ),
3563
+ getIndexedMessageCount: db.prepare(
3564
+ "SELECT COUNT(*) as cnt FROM conversation_message_index WHERE conversation_id = ?"
3491
3565
  )
3492
3566
  };
3493
3567
  }
@@ -3498,6 +3572,258 @@ var ConversationCache = class _ConversationCache {
3498
3572
  getDatabase() {
3499
3573
  return this.db;
3500
3574
  }
3575
+ // ── Offset index (design 1b) ────────────────────────────────────────────
3576
+ // conversation_file_state + conversation_message_index back the windowed
3577
+ // detail read path (SQL window select + pread of byte ranges). All methods
3578
+ // are thin wrappers over the prepared statements above.
3579
+ getFileState(path) {
3580
+ return this.stmts.getFileState.get(path) ?? null;
3581
+ }
3582
+ upsertFileState(row) {
3583
+ this.stmts.upsertFileState.run(row);
3584
+ }
3585
+ /** Drop a file's index rows + file_state (truncation / identity change). */
3586
+ deleteFileIndex(path, conversationId) {
3587
+ const tx = this.db.transaction(() => {
3588
+ this.stmts.deleteMessageIndex.run(conversationId);
3589
+ this.stmts.deleteFileState.run(path);
3590
+ });
3591
+ tx();
3592
+ this.indexParseState.delete(path);
3593
+ }
3594
+ /** Append/replace index rows in one transaction. */
3595
+ appendMessageIndexRows(rows) {
3596
+ const tx = this.db.transaction((batch) => {
3597
+ for (const r of batch) this.stmts.insertMessageIndexRow.run(r);
3598
+ });
3599
+ tx(rows);
3600
+ }
3601
+ /** Rows for message_index in [fromIndex, toIndex), ordered ascending. */
3602
+ getMessageIndexWindow(conversationId, fromIndex, toIndex) {
3603
+ return this.stmts.getMessageIndexWindow.all(
3604
+ conversationId,
3605
+ fromIndex,
3606
+ toIndex
3607
+ );
3608
+ }
3609
+ getIndexedMessageCount(conversationId) {
3610
+ return this.stmts.getIndexedMessageCount.get(conversationId).cnt;
3611
+ }
3612
+ /**
3613
+ * Conversation id for a JSONL path — the filename stem (matches the pseudo-id
3614
+ * updateFromLine derives and the uuid the detail read path resolves). The
3615
+ * offset index keys on this so the window select and the cursor agree.
3616
+ */
3617
+ static conversationIdForFile(filePath) {
3618
+ return filePath.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? filePath;
3619
+ }
3620
+ /**
3621
+ * Incremental offset-index writer: extend the index for a burst of appended
3622
+ * lines (one watcher read) using their byte spans. Each line is classified
3623
+ * with the scanner's parseJsonlLine (a running per-file reducer state), so the
3624
+ * message ordering can never drift from the scanner's. Message lines get an
3625
+ * index row at the next message_index; non-message lines (summary/sidecar)
3626
+ * get no row but still advance byte_offset. file_state is updated to the end
3627
+ * of the last consumed span.
3628
+ *
3629
+ * Requires an up-to-date `stat` (identity/size/mtime) for the file so the read
3630
+ * path can detect truncation/replacement.
3631
+ *
3632
+ * `readFrom` is the absolute byte offset the watcher read started at, and
3633
+ * `endOffset` is where it ended (readFrom + consumed, i.e. the watcher's new
3634
+ * entry.offset). CONTIGUITY GUARD: the read must begin exactly where the index
3635
+ * left off (`readFrom === existing.byte_offset`, or 0 with no state). If it
3636
+ * doesn't — the watcher attached at EOF after the server was down, or an
3637
+ * append raced an in-flight backfill — extending would assign wrong
3638
+ * message_index values over a hole. In that case this writes nothing and
3639
+ * returns null so the caller drops the index and backfills.
3640
+ *
3641
+ * On success returns the message_index assigned to each input span (null for a
3642
+ * non-message line) so the caller can stamp WS `seq`. Empty array when spans
3643
+ * is empty. `endOffset` is stored verbatim as byte_offset so the watcher's
3644
+ * offset and file_state.byte_offset are the same number by construction.
3645
+ */
3646
+ extendMessageIndex(filePath, spans, stat3, readFrom, endOffset) {
3647
+ const existing = this.getFileState(filePath);
3648
+ const expectedStart = existing?.byte_offset ?? 0;
3649
+ if (readFrom !== expectedStart) return null;
3650
+ if (spans.length === 0) return [];
3651
+ const convId = _ConversationCache.conversationIdForFile(filePath);
3652
+ let state = this.indexParseState.get(filePath);
3653
+ if (!state) {
3654
+ state = (0, import_scanner2.createJsonlParseState)();
3655
+ this.indexParseState.set(filePath, state);
3656
+ }
3657
+ let nextIndex = existing ? existing.last_message_index + 1 : 0;
3658
+ const rows = [];
3659
+ const seqs = [];
3660
+ for (const span of spans) {
3661
+ const msg = (0, import_scanner2.parseJsonlLine)(span.text, state);
3662
+ if (!msg) {
3663
+ seqs.push(null);
3664
+ continue;
3665
+ }
3666
+ rows.push({
3667
+ conversation_id: convId,
3668
+ message_index: nextIndex,
3669
+ byte_offset: span.byteOffset,
3670
+ byte_length: span.byteLength,
3671
+ uuid: msg.uuid ?? null,
3672
+ role: msg.role ?? null,
3673
+ ts: msg.timestamp ? Date.parse(msg.timestamp) || null : null
3674
+ });
3675
+ seqs.push(nextIndex);
3676
+ nextIndex++;
3677
+ }
3678
+ const tx = this.db.transaction(() => {
3679
+ for (const r of rows) this.stmts.insertMessageIndexRow.run(r);
3680
+ this.stmts.upsertFileState.run({
3681
+ path: filePath,
3682
+ identity: fileIdentity(stat3),
3683
+ size: stat3.size,
3684
+ mtime_ms: Math.round(stat3.mtimeMs),
3685
+ // Store the watcher's end offset verbatim — same number as entry.offset,
3686
+ // so the next read's contiguity check compares like-for-like (never
3687
+ // false-positive on a read that ended in trailing empty lines).
3688
+ byte_offset: endOffset,
3689
+ last_message_index: nextIndex - 1
3690
+ });
3691
+ });
3692
+ tx();
3693
+ return seqs;
3694
+ }
3695
+ clearIndexParseState(filePath) {
3696
+ this.indexParseState.delete(filePath);
3697
+ }
3698
+ /**
3699
+ * On-demand full backfill of the offset index for a file with no/stale
3700
+ * file_state (cold conversation, or after a truncation/replacement). Rebuilds
3701
+ * from byte 0: drops any existing rows, walks the whole file in chunks with a
3702
+ * running parse state, yields to the event loop every ~1000 lines so a large
3703
+ * file never blocks, and writes index rows + file_state.
3704
+ *
3705
+ * Single-flighted per path: concurrent callers await the same walk. The
3706
+ * triggering detail request is served by the scanner fallback while this runs.
3707
+ */
3708
+ backfillIndex(filePath) {
3709
+ const inFlight = this.backfillInFlight.get(filePath);
3710
+ if (inFlight) return inFlight;
3711
+ const walk = this.runBackfill(filePath).finally(() => {
3712
+ this.backfillInFlight.delete(filePath);
3713
+ });
3714
+ this.backfillInFlight.set(filePath, walk);
3715
+ return walk;
3716
+ }
3717
+ async runBackfill(filePath) {
3718
+ const convId = _ConversationCache.conversationIdForFile(filePath);
3719
+ this.deleteFileIndex(filePath, convId);
3720
+ this.indexParseState.delete(filePath);
3721
+ const CHUNK = 256 * 1024;
3722
+ const YIELD_EVERY = 1e3;
3723
+ const state = (0, import_scanner2.createJsonlParseState)();
3724
+ const fh = await (0, import_promises3.open)(filePath, "r");
3725
+ let fileOffset = 0;
3726
+ let carry = Buffer.alloc(0);
3727
+ let nextIndex = 0;
3728
+ let linesSinceYield = 0;
3729
+ let lastConsumedEnd = 0;
3730
+ let stat3;
3731
+ try {
3732
+ stat3 = await fh.stat();
3733
+ const buf = Buffer.alloc(CHUNK);
3734
+ for (; ; ) {
3735
+ const { bytesRead } = await fh.read(buf, 0, CHUNK, null);
3736
+ if (bytesRead === 0) break;
3737
+ const combined = carry.length > 0 ? Buffer.concat([carry, buf.subarray(0, bytesRead)]) : buf.subarray(0, bytesRead);
3738
+ const { spans, consumed } = splitCompleteLines(combined, fileOffset);
3739
+ const rows = [];
3740
+ for (const span of spans) {
3741
+ const msg = (0, import_scanner2.parseJsonlLine)(span.text, state);
3742
+ linesSinceYield++;
3743
+ if (msg) {
3744
+ rows.push({
3745
+ conversation_id: convId,
3746
+ message_index: nextIndex,
3747
+ byte_offset: span.byteOffset,
3748
+ byte_length: span.byteLength,
3749
+ uuid: msg.uuid ?? null,
3750
+ role: msg.role ?? null,
3751
+ ts: msg.timestamp ? Date.parse(msg.timestamp) || null : null
3752
+ });
3753
+ nextIndex++;
3754
+ }
3755
+ if (linesSinceYield >= YIELD_EVERY) {
3756
+ linesSinceYield = 0;
3757
+ await (0, import_promises4.setImmediate)();
3758
+ }
3759
+ }
3760
+ if (rows.length > 0) this.appendMessageIndexRows(rows);
3761
+ lastConsumedEnd = fileOffset + consumed;
3762
+ carry = Buffer.from(combined.subarray(consumed));
3763
+ fileOffset += consumed;
3764
+ }
3765
+ } finally {
3766
+ await fh.close();
3767
+ }
3768
+ this.upsertFileState({
3769
+ path: filePath,
3770
+ identity: fileIdentity(stat3),
3771
+ size: stat3.size,
3772
+ mtime_ms: Math.round(stat3.mtimeMs),
3773
+ byte_offset: lastConsumedEnd,
3774
+ last_message_index: nextIndex - 1
3775
+ });
3776
+ this.indexParseState.set(filePath, state);
3777
+ }
3778
+ /**
3779
+ * Windowed detail read straight from the offset index — the hot path.
3780
+ * Returns the parsed messages for message_index in [fromIndex, toIndex) plus
3781
+ * the total indexed count, or null when the index can't serve this file (no
3782
+ * file_state, identity/size mismatch = truncation/replacement, or cold index)
3783
+ * so the caller falls back to the scanner and enqueues a backfill.
3784
+ *
3785
+ * On a match it SQL-selects the window's byte ranges and preads exactly those
3786
+ * ranges from the JSONL (never the whole file), parsing only the sliced lines.
3787
+ * Returns messages in the same ConversationMessage shape parseJsonlLine
3788
+ * produces during a scan, so the payload is identical to the scanner path.
3789
+ */
3790
+ readMessageWindow(filePath, fromIndex, toIndex) {
3791
+ const fileState = this.getFileState(filePath);
3792
+ if (!fileState) return null;
3793
+ let stat3;
3794
+ try {
3795
+ stat3 = (0, import_fs8.statSync)(filePath);
3796
+ } catch {
3797
+ return null;
3798
+ }
3799
+ if (fileIdentity(stat3) !== fileState.identity || stat3.size !== fileState.byte_offset) {
3800
+ return null;
3801
+ }
3802
+ const total = fileState.last_message_index + 1;
3803
+ const from = Math.max(0, fromIndex);
3804
+ const to = Math.min(toIndex, total);
3805
+ if (to <= from) return { messages: [], total, fromIndex: from };
3806
+ const rows = this.getMessageIndexWindow(
3807
+ _ConversationCache.conversationIdForFile(filePath),
3808
+ from,
3809
+ to
3810
+ );
3811
+ if (rows.length === 0) return { messages: [], total, fromIndex: from };
3812
+ const messages = [];
3813
+ const fd = (0, import_fs8.openSync)(filePath, "r");
3814
+ try {
3815
+ const state = (0, import_scanner2.createJsonlParseState)();
3816
+ for (const row of rows) {
3817
+ const buf = Buffer.alloc(row.byte_length);
3818
+ (0, import_fs8.readSync)(fd, buf, 0, row.byte_length, row.byte_offset);
3819
+ const msg = (0, import_scanner2.parseJsonlLine)(buf.toString("utf-8"), state);
3820
+ if (msg) messages.push(msg);
3821
+ }
3822
+ } finally {
3823
+ (0, import_fs8.closeSync)(fd);
3824
+ }
3825
+ return { messages, total, fromIndex: from };
3826
+ }
3501
3827
  agentEntrypointsKey() {
3502
3828
  return [...this.agentEntrypoints].sort().join(",");
3503
3829
  }
@@ -4131,7 +4457,7 @@ var ConversationsRepository = class {
4131
4457
  };
4132
4458
 
4133
4459
  // src/db/repositories/projects.repository.ts
4134
- var import_crypto5 = require("crypto");
4460
+ var import_crypto6 = require("crypto");
4135
4461
 
4136
4462
  // src/utils/canonicalizeProjectPath.ts
4137
4463
  function canonicalizeProjectPath(projectPath) {
@@ -4224,7 +4550,7 @@ var ProjectsRepository = class {
4224
4550
  });
4225
4551
  return rowToProject(this.getById.get(existing.id));
4226
4552
  }
4227
- const id = (0, import_crypto5.randomUUID)();
4553
+ const id = (0, import_crypto6.randomUUID)();
4228
4554
  this.insert.run({
4229
4555
  id,
4230
4556
  path,
@@ -4315,7 +4641,7 @@ function handleListProjects(url, res) {
4315
4641
  }
4316
4642
 
4317
4643
  // src/pair-store.ts
4318
- var import_crypto6 = require("crypto");
4644
+ var import_crypto7 = require("crypto");
4319
4645
  var DEFAULT_TTL_SECONDS = 180;
4320
4646
  var SWEEP_INTERVAL_MS = 6e4;
4321
4647
  var PairTokenStore = class {
@@ -4330,7 +4656,7 @@ var PairTokenStore = class {
4330
4656
  }
4331
4657
  }
4332
4658
  mint() {
4333
- const token = `pt_${(0, import_crypto6.randomBytes)(16).toString("hex")}`;
4659
+ const token = `pt_${(0, import_crypto7.randomBytes)(16).toString("hex")}`;
4334
4660
  const expiresAt = Date.now() + this.ttlMs;
4335
4661
  this.current = { token, expiresAt, used: false };
4336
4662
  return {
@@ -4392,18 +4718,20 @@ function seal(plaintext, recipientPublicKeyBase64) {
4392
4718
  // src/services/conversations/conversationWatcher.ts
4393
4719
  var import_chokidar = __toESM(require("chokidar"), 1);
4394
4720
  var import_fs10 = require("fs");
4395
- var import_promises3 = require("fs/promises");
4721
+ var import_promises5 = require("fs/promises");
4396
4722
  var ConversationWatcher = class {
4397
4723
  files = /* @__PURE__ */ new Map();
4398
4724
  directories = /* @__PURE__ */ new Map();
4399
4725
  onNewLine;
4400
4726
  onNewLines;
4727
+ onNewLineSpans;
4401
4728
  onConversationChanged;
4402
4729
  onFileDeleted;
4403
4730
  onError;
4404
4731
  constructor(events = {}) {
4405
4732
  this.onNewLine = events.onNewLine;
4406
4733
  this.onNewLines = events.onNewLines;
4734
+ this.onNewLineSpans = events.onNewLineSpans;
4407
4735
  this.onConversationChanged = events.onConversationChanged;
4408
4736
  this.onFileDeleted = events.onFileDeleted;
4409
4737
  this.onError = events.onError;
@@ -4496,20 +4824,24 @@ var ConversationWatcher = class {
4496
4824
  entry.reading = true;
4497
4825
  try {
4498
4826
  for (; ; ) {
4499
- const st = await (0, import_promises3.stat)(filePath);
4827
+ const st = await (0, import_promises5.stat)(filePath);
4500
4828
  if (st.size <= entry.offset) break;
4501
4829
  const readFrom = entry.offset;
4502
4830
  const bytesToRead = st.size - readFrom;
4503
4831
  const buf = Buffer.alloc(bytesToRead);
4504
- const fh = await (0, import_promises3.open)(filePath, "r");
4832
+ const fh = await (0, import_promises5.open)(filePath, "r");
4505
4833
  try {
4506
4834
  await fh.read(buf, 0, bytesToRead, readFrom);
4507
4835
  } finally {
4508
4836
  await fh.close();
4509
4837
  }
4510
- entry.offset = readFrom + bytesToRead;
4838
+ const { spans, consumed } = splitCompleteLines(buf, readFrom);
4839
+ entry.offset = readFrom + consumed;
4511
4840
  if (!this.files.has(filePath)) return;
4512
- const lines = buf.toString("utf-8").split("\n").filter(Boolean);
4841
+ const lines = spans.map((s) => s.text);
4842
+ if (spans.length > 0) {
4843
+ this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
4844
+ }
4513
4845
  if (this.onNewLines) {
4514
4846
  this.onNewLines(filePath, lines);
4515
4847
  } else {
@@ -4923,8 +5255,8 @@ function discoveredToResponse(d, conversationId) {
4923
5255
  }
4924
5256
 
4925
5257
  // src/uploads.ts
4926
- var import_crypto7 = require("crypto");
4927
- var import_promises4 = require("fs/promises");
5258
+ var import_crypto8 = require("crypto");
5259
+ var import_promises6 = require("fs/promises");
4928
5260
  var import_heic_convert = __toESM(require("heic-convert"), 1);
4929
5261
  var import_path12 = require("path");
4930
5262
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
@@ -4957,12 +5289,12 @@ async function saveUploadFile(input) {
4957
5289
  mimeType = "image/jpeg";
4958
5290
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
4959
5291
  }
4960
- const id = `up_${(0, import_crypto7.randomBytes)(8).toString("hex")}`;
5292
+ const id = `up_${(0, import_crypto8.randomBytes)(8).toString("hex")}`;
4961
5293
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
4962
5294
  const dir = (0, import_path12.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
4963
- await (0, import_promises4.mkdir)(dir, { recursive: true });
5295
+ await (0, import_promises6.mkdir)(dir, { recursive: true });
4964
5296
  const filePath = (0, import_path12.join)(dir, `${Date.now()}-${id}-${safeName}`);
4965
- await (0, import_promises4.writeFile)(filePath, buffer);
5297
+ await (0, import_promises6.writeFile)(filePath, buffer);
4966
5298
  return {
4967
5299
  id,
4968
5300
  filePath,
@@ -5149,6 +5481,10 @@ var StreamerServer = class {
5149
5481
  fileWatcher;
5150
5482
  sessionFileMap = /* @__PURE__ */ new Map();
5151
5483
  // sessionId → JSONL filePath
5484
+ // Per-file seq assignments from the most recent onNewLineSpans (offset index),
5485
+ // handed to the immediately-following onNewLines so it can stamp WS `seq` on
5486
+ // the matching conversation_events entries. Same read → same lines order.
5487
+ pendingLineSeqs = /* @__PURE__ */ new Map();
5152
5488
  pendingQuestions = /* @__PURE__ */ new Map();
5153
5489
  // Content key of the AskUserQuestion currently broadcast for a session (from
5154
5490
  // either the rendered screen or JSONL), used to de-dupe the two paths: when
@@ -5272,7 +5608,7 @@ var StreamerServer = class {
5272
5608
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
5273
5609
  const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
5274
5610
  if (rawRoot) {
5275
- (0, import_promises5.realpath)(rawRoot).then((resolved) => {
5611
+ (0, import_promises7.realpath)(rawRoot).then((resolved) => {
5276
5612
  this.browseRoot = resolved;
5277
5613
  if (this.verbose) this.log.info(`Browse root: ${resolved}`, { browseRoot: resolved });
5278
5614
  }).catch(() => {
@@ -5294,6 +5630,42 @@ var StreamerServer = class {
5294
5630
  this.sessionStore = new SessionStore();
5295
5631
  this.wsHub = new WSHub();
5296
5632
  this.fileWatcher = new ConversationWatcher({
5633
+ onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
5634
+ if (!this.cache) return;
5635
+ const cache = this.cache;
5636
+ this.pendingLineSeqs.delete(filePath);
5637
+ try {
5638
+ const seqs = cache.extendMessageIndex(
5639
+ filePath,
5640
+ spans,
5641
+ (0, import_fs12.statSync)(filePath),
5642
+ readFrom,
5643
+ endOffset
5644
+ );
5645
+ if (seqs === null) {
5646
+ cache.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
5647
+ cache.clearIndexParseState(filePath);
5648
+ this.trackCacheWrite(
5649
+ cache.backfillIndex(filePath).catch((err) => {
5650
+ this.log.warn("offset-index.backfill_failed", {
5651
+ event: "offset_index.backfill_failed",
5652
+ filePath,
5653
+ trigger: "noncontiguous-append",
5654
+ err
5655
+ });
5656
+ })
5657
+ );
5658
+ return;
5659
+ }
5660
+ this.pendingLineSeqs.set(filePath, seqs);
5661
+ } catch (err) {
5662
+ this.log.warn("offset-index.extend_failed", {
5663
+ event: "offset_index.extend_failed",
5664
+ filePath,
5665
+ err
5666
+ });
5667
+ }
5668
+ },
5297
5669
  onNewLines: (filePath, lines) => {
5298
5670
  this.cache?.updateFromLines(filePath, lines);
5299
5671
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
@@ -5320,13 +5692,20 @@ var StreamerServer = class {
5320
5692
  this.pendingQuestionKey.set(sessionId, key);
5321
5693
  if (broadcast) this.wsHub.broadcast(m);
5322
5694
  }
5323
- this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
5695
+ const seqs = this.pendingLineSeqs.get(filePath);
5696
+ this.wsHub.broadcast({
5697
+ type: "conversation_events",
5698
+ sessionId,
5699
+ lines,
5700
+ ...seqs && seqs.length === lines.length ? { seqs } : {}
5701
+ });
5324
5702
  for (const line of lines) {
5325
5703
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
5326
5704
  }
5327
5705
  break;
5328
5706
  }
5329
5707
  }
5708
+ this.pendingLineSeqs.delete(filePath);
5330
5709
  },
5331
5710
  onConversationChanged: (filePath) => {
5332
5711
  this.fileWatcher.poke(filePath);
@@ -6049,13 +6428,13 @@ var StreamerServer = class {
6049
6428
  }
6050
6429
  const scanner = await this.getScanner();
6051
6430
  let metas = [...scanner.getMetadataCache().values()];
6052
- metas = (0, import_scanner2.applyIncludeFilter)(metas, "conversations");
6053
- if (project) metas = (0, import_scanner2.applyProjectFilter)(metas, project);
6431
+ metas = (0, import_scanner3.applyIncludeFilter)(metas, "conversations");
6432
+ if (project) metas = (0, import_scanner3.applyProjectFilter)(metas, project);
6054
6433
  if (providerFilter)
6055
6434
  metas = metas.filter((m) => (m.provider ?? CLAUDE_CODE_PROVIDER) === providerFilter);
6056
- metas = (0, import_scanner2.applySort)(metas, sort);
6435
+ metas = (0, import_scanner3.applySort)(metas, sort);
6057
6436
  const total = metas.length;
6058
- const page = (0, import_scanner2.applyPagination)(metas, limit, offset);
6437
+ const page = (0, import_scanner3.applyPagination)(metas, limit, offset);
6059
6438
  const adapted = page.items.map((c) => {
6060
6439
  const id = c.sessionId || c.id.split("/").pop()?.replace(/\.jsonl$/, "") || c.id;
6061
6440
  return {
@@ -6099,8 +6478,8 @@ var StreamerServer = class {
6099
6478
  }
6100
6479
  const scanner = await this.getScanner(true);
6101
6480
  let metas = [...scanner.getMetadataCache().values()];
6102
- metas = (0, import_scanner2.applyIncludeFilter)(metas, "conversations");
6103
- if (project) metas = (0, import_scanner2.applyProjectFilter)(metas, project);
6481
+ metas = (0, import_scanner3.applyIncludeFilter)(metas, "conversations");
6482
+ if (project) metas = (0, import_scanner3.applyProjectFilter)(metas, project);
6104
6483
  if (providerFilter)
6105
6484
  metas = metas.filter((m) => (m.provider ?? CLAUDE_CODE_PROVIDER) === providerFilter);
6106
6485
  json(res, 200, { total: metas.length });
@@ -6197,7 +6576,7 @@ var StreamerServer = class {
6197
6576
  // reconciles the one conversation being requested, so paying a full-tree
6198
6577
  // rescan just because some OTHER file changed is the stall this avoids.
6199
6578
  newScanner(options) {
6200
- return new import_scanner2.ConversationScanner(
6579
+ return new import_scanner3.ConversationScanner(
6201
6580
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
6202
6581
  );
6203
6582
  }
@@ -6251,7 +6630,7 @@ var StreamerServer = class {
6251
6630
  if (this.scannerReady) await this.scannerReady;
6252
6631
  this.scannerStale = false;
6253
6632
  if (!this.scanner) {
6254
- this.scanner = new import_scanner2.ConversationScanner();
6633
+ this.scanner = new import_scanner3.ConversationScanner();
6255
6634
  this.allScanners.add(this.scanner);
6256
6635
  }
6257
6636
  const scanner = this.scanner;
@@ -6410,9 +6789,13 @@ var StreamerServer = class {
6410
6789
  return;
6411
6790
  }
6412
6791
  const etagSource = conversation;
6792
+ const indexedCount = etagSource.filePath && this.cache ? this.cache.getIndexedMessageCount(
6793
+ ConversationCache.conversationIdForFile(etagSource.filePath)
6794
+ ) : 0;
6795
+ const etagMessageCount = Math.max(etagSource.messageCount, indexedCount);
6413
6796
  const etag = computeConversationEtag({
6414
6797
  filePath: etagSource.filePath,
6415
- messageCount: etagSource.messageCount,
6798
+ messageCount: etagMessageCount,
6416
6799
  timestamp: etagSource.timestamp
6417
6800
  });
6418
6801
  const isFirstPage = !url.searchParams.has("before_index") && !url.searchParams.has("anchor_index") && !url.searchParams.has("after_index");
@@ -6429,12 +6812,14 @@ var StreamerServer = class {
6429
6812
  let slice = filtered;
6430
6813
  let fromIdx = 0;
6431
6814
  let messagePagination;
6815
+ let indexTotal = null;
6432
6816
  if (usePaging) {
6433
6817
  const limit = Math.min(Math.max(intParam(url, "msg_limit", 80), 1), 500);
6434
6818
  let beforeIndex = total;
6435
6819
  let scanLimit = limit;
6436
6820
  let anchorIndex = null;
6437
6821
  let newerPaging = false;
6822
+ let usedAfterIndex = false;
6438
6823
  if (url.searchParams.has("before_index")) {
6439
6824
  beforeIndex = intParam(url, "before_index", total);
6440
6825
  beforeIndex = Math.min(Math.max(beforeIndex, 0), total);
@@ -6443,6 +6828,7 @@ var StreamerServer = class {
6443
6828
  beforeIndex = Math.min(total, from + limit);
6444
6829
  scanLimit = beforeIndex - from;
6445
6830
  newerPaging = true;
6831
+ usedAfterIndex = true;
6446
6832
  } else if (hasAnchor) {
6447
6833
  anchorIndex = Math.min(
6448
6834
  Math.max(intParam(url, "anchor_index", 0), 0),
@@ -6452,9 +6838,34 @@ var StreamerServer = class {
6452
6838
  beforeIndex = Math.min(total, from + limit);
6453
6839
  newerPaging = true;
6454
6840
  }
6455
- const pagedScanner = this.scannerReady ? await this.getScanner(true) : null;
6456
- const page = scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null;
6457
- const start = page?.fromIndex ?? Math.max(0, beforeIndex - scanLimit);
6841
+ const isTailRequest = !url.searchParams.has("before_index") && !hasAfter && !hasAnchor;
6842
+ const indexFilePath = conversation.filePath;
6843
+ if (isTailRequest && indexFilePath && this.cache) {
6844
+ const indexed = this.cache.getIndexedMessageCount(
6845
+ ConversationCache.conversationIdForFile(indexFilePath)
6846
+ );
6847
+ if (indexed > beforeIndex) {
6848
+ beforeIndex = indexed;
6849
+ }
6850
+ }
6851
+ const windowStart = Math.max(0, beforeIndex - scanLimit);
6852
+ const indexWindow = scanLimit > 0 && !hasAnchor && indexFilePath && this.cache ? this.cache.readMessageWindow(indexFilePath, windowStart, beforeIndex) : null;
6853
+ if (!indexWindow && indexFilePath && this.cache && !hasAnchor) {
6854
+ this.trackCacheWrite(
6855
+ this.cache.backfillIndex(indexFilePath).catch((err) => {
6856
+ this.log.warn("offset-index.backfill_failed", {
6857
+ event: "offset_index.backfill_failed",
6858
+ conversationId: id,
6859
+ filePath: indexFilePath,
6860
+ err
6861
+ });
6862
+ })
6863
+ );
6864
+ }
6865
+ const pagedScanner = !indexWindow && this.scannerReady ? await this.getScanner(true) : null;
6866
+ const page = indexWindow ?? (scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null);
6867
+ if (indexWindow) indexTotal = indexWindow.total;
6868
+ const start = page?.fromIndex ?? windowStart;
6458
6869
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
6459
6870
  fromIdx = start;
6460
6871
  const effectiveTotal = page?.total ?? total;
@@ -6470,6 +6881,9 @@ var StreamerServer = class {
6470
6881
  messagePagination.has_more_newer = beforeIndex < effectiveTotal;
6471
6882
  messagePagination.next_after_index = beforeIndex < effectiveTotal ? beforeIndex : null;
6472
6883
  }
6884
+ if (usedAfterIndex) {
6885
+ messagePagination.etag = etag;
6886
+ }
6473
6887
  }
6474
6888
  const messagesPayload = slice.map((m, localIdx) => {
6475
6889
  const content = [];
@@ -6511,6 +6925,8 @@ var StreamerServer = class {
6511
6925
  const cachedConvMeta = this.cache?.getMetaById(id);
6512
6926
  const convProvider = coerceProviderForRunner(conv.provider ?? cachedConvMeta?.provider);
6513
6927
  const availability = classifyResumability(conv.projectPath);
6928
+ const metaMessageCount = indexTotal != null && indexTotal > conv.messageCount ? indexTotal : conv.messageCount;
6929
+ const metaLastUpdatedAt = indexTotal != null && indexTotal > conv.messageCount ? slice.at(-1)?.timestamp ?? conv.timestamp : conv.timestamp;
6514
6930
  const body = {
6515
6931
  meta: {
6516
6932
  id,
@@ -6518,8 +6934,8 @@ var StreamerServer = class {
6518
6934
  project_name: conv.projectName,
6519
6935
  project_path: conv.projectPath,
6520
6936
  file_path: conv.filePath,
6521
- last_updated_at: conv.timestamp,
6522
- message_count: conv.messageCount,
6937
+ last_updated_at: metaLastUpdatedAt,
6938
+ message_count: metaMessageCount,
6523
6939
  last_prompt: conv.lastPrompt ?? void 0,
6524
6940
  provider: convProvider,
6525
6941
  resumable: isProviderResumable(convProvider, availability.resumable),
@@ -6611,7 +7027,7 @@ var StreamerServer = class {
6611
7027
  }
6612
7028
  const limit = intParam(url, "limit", 50);
6613
7029
  const scanner = await this.getScanner();
6614
- const results = await (0, import_scanner2.search)(
7030
+ const results = await (0, import_scanner3.search)(
6615
7031
  q,
6616
7032
  {
6617
7033
  limit,