@threadbase-sh/streamer 1.27.3 → 1.28.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.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,273 @@ 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
+ // The offset index understands only claude-code JSONL: parseJsonlLine is the
3621
+ // claude-code reducer, so a codex file "indexes" as zero messages and the
3622
+ // resulting file_state serves empty windows for a real conversation (the
3623
+ // silent-wrong-data bug hotfixed after 1.28.0). Non-claude providers are
3624
+ // excluded from the index entirely; the scanner serves them (it routes each
3625
+ // provider to its own parser).
3626
+ isIndexableFile(filePath) {
3627
+ const meta = this.getMetaById(_ConversationCache.conversationIdForFile(filePath));
3628
+ return (meta?.provider ?? CLAUDE_CODE_PROVIDER) === CLAUDE_CODE_PROVIDER;
3629
+ }
3630
+ /**
3631
+ * Incremental offset-index writer: extend the index for a burst of appended
3632
+ * lines (one watcher read) using their byte spans. Each line is classified
3633
+ * with the scanner's parseJsonlLine (a running per-file reducer state), so the
3634
+ * message ordering can never drift from the scanner's. Message lines get an
3635
+ * index row at the next message_index; non-message lines (summary/sidecar)
3636
+ * get no row but still advance byte_offset. file_state is updated to the end
3637
+ * of the last consumed span.
3638
+ *
3639
+ * Requires an up-to-date `stat` (identity/size/mtime) for the file so the read
3640
+ * path can detect truncation/replacement.
3641
+ *
3642
+ * `readFrom` is the absolute byte offset the watcher read started at, and
3643
+ * `endOffset` is where it ended (readFrom + consumed, i.e. the watcher's new
3644
+ * entry.offset). CONTIGUITY GUARD: the read must begin exactly where the index
3645
+ * left off (`readFrom === existing.byte_offset`, or 0 with no state). If it
3646
+ * doesn't — the watcher attached at EOF after the server was down, or an
3647
+ * append raced an in-flight backfill — extending would assign wrong
3648
+ * message_index values over a hole. In that case this writes nothing and
3649
+ * returns null so the caller drops the index and backfills.
3650
+ *
3651
+ * On success returns the message_index assigned to each input span (null for a
3652
+ * non-message line) so the caller can stamp WS `seq`. Empty array when spans
3653
+ * is empty. `endOffset` is stored verbatim as byte_offset so the watcher's
3654
+ * offset and file_state.byte_offset are the same number by construction.
3655
+ */
3656
+ extendMessageIndex(filePath, spans, stat3, readFrom, endOffset) {
3657
+ if (!this.isIndexableFile(filePath)) return spans.map(() => null);
3658
+ const existing = this.getFileState(filePath);
3659
+ const expectedStart = existing?.byte_offset ?? 0;
3660
+ if (readFrom !== expectedStart) return null;
3661
+ if (spans.length === 0) return [];
3662
+ const convId = _ConversationCache.conversationIdForFile(filePath);
3663
+ let state = this.indexParseState.get(filePath);
3664
+ if (!state) {
3665
+ state = (0, import_scanner2.createJsonlParseState)();
3666
+ this.indexParseState.set(filePath, state);
3667
+ }
3668
+ let nextIndex = existing ? existing.last_message_index + 1 : 0;
3669
+ const rows = [];
3670
+ const seqs = [];
3671
+ for (const span of spans) {
3672
+ const msg = (0, import_scanner2.parseJsonlLine)(span.text, state);
3673
+ if (!msg) {
3674
+ seqs.push(null);
3675
+ continue;
3676
+ }
3677
+ rows.push({
3678
+ conversation_id: convId,
3679
+ message_index: nextIndex,
3680
+ byte_offset: span.byteOffset,
3681
+ byte_length: span.byteLength,
3682
+ uuid: msg.uuid ?? null,
3683
+ role: msg.role ?? null,
3684
+ ts: msg.timestamp ? Date.parse(msg.timestamp) || null : null
3685
+ });
3686
+ seqs.push(nextIndex);
3687
+ nextIndex++;
3688
+ }
3689
+ const tx = this.db.transaction(() => {
3690
+ for (const r of rows) this.stmts.insertMessageIndexRow.run(r);
3691
+ this.stmts.upsertFileState.run({
3692
+ path: filePath,
3693
+ identity: fileIdentity(stat3),
3694
+ size: stat3.size,
3695
+ mtime_ms: Math.round(stat3.mtimeMs),
3696
+ // Store the watcher's end offset verbatim — same number as entry.offset,
3697
+ // so the next read's contiguity check compares like-for-like (never
3698
+ // false-positive on a read that ended in trailing empty lines).
3699
+ byte_offset: endOffset,
3700
+ last_message_index: nextIndex - 1
3701
+ });
3702
+ });
3703
+ tx();
3704
+ return seqs;
3705
+ }
3706
+ clearIndexParseState(filePath) {
3707
+ this.indexParseState.delete(filePath);
3708
+ }
3709
+ /**
3710
+ * On-demand full backfill of the offset index for a file with no/stale
3711
+ * file_state (cold conversation, or after a truncation/replacement). Rebuilds
3712
+ * from byte 0: drops any existing rows, walks the whole file in chunks with a
3713
+ * running parse state, yields to the event loop every ~1000 lines so a large
3714
+ * file never blocks, and writes index rows + file_state.
3715
+ *
3716
+ * Single-flighted per path: concurrent callers await the same walk. The
3717
+ * triggering detail request is served by the scanner fallback while this runs.
3718
+ */
3719
+ backfillIndex(filePath) {
3720
+ const inFlight = this.backfillInFlight.get(filePath);
3721
+ if (inFlight) return inFlight;
3722
+ const walk = this.runBackfill(filePath).finally(() => {
3723
+ this.backfillInFlight.delete(filePath);
3724
+ });
3725
+ this.backfillInFlight.set(filePath, walk);
3726
+ return walk;
3727
+ }
3728
+ async runBackfill(filePath) {
3729
+ const convId = _ConversationCache.conversationIdForFile(filePath);
3730
+ this.deleteFileIndex(filePath, convId);
3731
+ this.indexParseState.delete(filePath);
3732
+ if (!this.isIndexableFile(filePath)) return;
3733
+ const CHUNK = 256 * 1024;
3734
+ const YIELD_EVERY = 1e3;
3735
+ const state = (0, import_scanner2.createJsonlParseState)();
3736
+ const fh = await (0, import_promises3.open)(filePath, "r");
3737
+ let fileOffset = 0;
3738
+ let carry = Buffer.alloc(0);
3739
+ let nextIndex = 0;
3740
+ let linesSinceYield = 0;
3741
+ let lastConsumedEnd = 0;
3742
+ let stat3;
3743
+ try {
3744
+ stat3 = await fh.stat();
3745
+ const buf = Buffer.alloc(CHUNK);
3746
+ for (; ; ) {
3747
+ const { bytesRead } = await fh.read(buf, 0, CHUNK, null);
3748
+ if (bytesRead === 0) break;
3749
+ const combined = carry.length > 0 ? Buffer.concat([carry, buf.subarray(0, bytesRead)]) : buf.subarray(0, bytesRead);
3750
+ const { spans, consumed } = splitCompleteLines(combined, fileOffset);
3751
+ const rows = [];
3752
+ for (const span of spans) {
3753
+ const msg = (0, import_scanner2.parseJsonlLine)(span.text, state);
3754
+ linesSinceYield++;
3755
+ if (msg) {
3756
+ rows.push({
3757
+ conversation_id: convId,
3758
+ message_index: nextIndex,
3759
+ byte_offset: span.byteOffset,
3760
+ byte_length: span.byteLength,
3761
+ uuid: msg.uuid ?? null,
3762
+ role: msg.role ?? null,
3763
+ ts: msg.timestamp ? Date.parse(msg.timestamp) || null : null
3764
+ });
3765
+ nextIndex++;
3766
+ }
3767
+ if (linesSinceYield >= YIELD_EVERY) {
3768
+ linesSinceYield = 0;
3769
+ await (0, import_promises4.setImmediate)();
3770
+ }
3771
+ }
3772
+ if (rows.length > 0) this.appendMessageIndexRows(rows);
3773
+ lastConsumedEnd = fileOffset + consumed;
3774
+ carry = Buffer.from(combined.subarray(consumed));
3775
+ fileOffset += consumed;
3776
+ }
3777
+ } finally {
3778
+ await fh.close();
3779
+ }
3780
+ this.upsertFileState({
3781
+ path: filePath,
3782
+ identity: fileIdentity(stat3),
3783
+ size: stat3.size,
3784
+ mtime_ms: Math.round(stat3.mtimeMs),
3785
+ byte_offset: lastConsumedEnd,
3786
+ last_message_index: nextIndex - 1
3787
+ });
3788
+ this.indexParseState.set(filePath, state);
3789
+ }
3790
+ /**
3791
+ * Windowed detail read straight from the offset index — the hot path.
3792
+ * Returns the parsed messages for message_index in [fromIndex, toIndex) plus
3793
+ * the total indexed count, or null when the index can't serve this file (no
3794
+ * file_state, identity/size mismatch = truncation/replacement, or cold index)
3795
+ * so the caller falls back to the scanner and enqueues a backfill.
3796
+ *
3797
+ * On a match it SQL-selects the window's byte ranges and preads exactly those
3798
+ * ranges from the JSONL (never the whole file), parsing only the sliced lines.
3799
+ * Returns messages in the same ConversationMessage shape parseJsonlLine
3800
+ * produces during a scan, so the payload is identical to the scanner path.
3801
+ */
3802
+ readMessageWindow(filePath, fromIndex, toIndex) {
3803
+ const fileState = this.getFileState(filePath);
3804
+ if (!fileState) return null;
3805
+ let stat3;
3806
+ try {
3807
+ stat3 = (0, import_fs8.statSync)(filePath);
3808
+ } catch {
3809
+ return null;
3810
+ }
3811
+ if (fileIdentity(stat3) !== fileState.identity || stat3.size !== fileState.byte_offset) {
3812
+ return null;
3813
+ }
3814
+ if (fileState.last_message_index < 0 || !this.isIndexableFile(filePath)) {
3815
+ return null;
3816
+ }
3817
+ const total = fileState.last_message_index + 1;
3818
+ const from = Math.max(0, fromIndex);
3819
+ const to = Math.min(toIndex, total);
3820
+ if (to <= from) return { messages: [], total, fromIndex: from };
3821
+ const rows = this.getMessageIndexWindow(
3822
+ _ConversationCache.conversationIdForFile(filePath),
3823
+ from,
3824
+ to
3825
+ );
3826
+ if (rows.length === 0) return { messages: [], total, fromIndex: from };
3827
+ const messages = [];
3828
+ const fd = (0, import_fs8.openSync)(filePath, "r");
3829
+ try {
3830
+ const state = (0, import_scanner2.createJsonlParseState)();
3831
+ for (const row of rows) {
3832
+ const buf = Buffer.alloc(row.byte_length);
3833
+ (0, import_fs8.readSync)(fd, buf, 0, row.byte_length, row.byte_offset);
3834
+ const msg = (0, import_scanner2.parseJsonlLine)(buf.toString("utf-8"), state);
3835
+ if (msg) messages.push(msg);
3836
+ }
3837
+ } finally {
3838
+ (0, import_fs8.closeSync)(fd);
3839
+ }
3840
+ return { messages, total, fromIndex: from };
3841
+ }
3501
3842
  agentEntrypointsKey() {
3502
3843
  return [...this.agentEntrypoints].sort().join(",");
3503
3844
  }
@@ -4131,7 +4472,7 @@ var ConversationsRepository = class {
4131
4472
  };
4132
4473
 
4133
4474
  // src/db/repositories/projects.repository.ts
4134
- var import_crypto5 = require("crypto");
4475
+ var import_crypto6 = require("crypto");
4135
4476
 
4136
4477
  // src/utils/canonicalizeProjectPath.ts
4137
4478
  function canonicalizeProjectPath(projectPath) {
@@ -4224,7 +4565,7 @@ var ProjectsRepository = class {
4224
4565
  });
4225
4566
  return rowToProject(this.getById.get(existing.id));
4226
4567
  }
4227
- const id = (0, import_crypto5.randomUUID)();
4568
+ const id = (0, import_crypto6.randomUUID)();
4228
4569
  this.insert.run({
4229
4570
  id,
4230
4571
  path,
@@ -4315,7 +4656,7 @@ function handleListProjects(url, res) {
4315
4656
  }
4316
4657
 
4317
4658
  // src/pair-store.ts
4318
- var import_crypto6 = require("crypto");
4659
+ var import_crypto7 = require("crypto");
4319
4660
  var DEFAULT_TTL_SECONDS = 180;
4320
4661
  var SWEEP_INTERVAL_MS = 6e4;
4321
4662
  var PairTokenStore = class {
@@ -4330,7 +4671,7 @@ var PairTokenStore = class {
4330
4671
  }
4331
4672
  }
4332
4673
  mint() {
4333
- const token = `pt_${(0, import_crypto6.randomBytes)(16).toString("hex")}`;
4674
+ const token = `pt_${(0, import_crypto7.randomBytes)(16).toString("hex")}`;
4334
4675
  const expiresAt = Date.now() + this.ttlMs;
4335
4676
  this.current = { token, expiresAt, used: false };
4336
4677
  return {
@@ -4392,18 +4733,20 @@ function seal(plaintext, recipientPublicKeyBase64) {
4392
4733
  // src/services/conversations/conversationWatcher.ts
4393
4734
  var import_chokidar = __toESM(require("chokidar"), 1);
4394
4735
  var import_fs10 = require("fs");
4395
- var import_promises3 = require("fs/promises");
4736
+ var import_promises5 = require("fs/promises");
4396
4737
  var ConversationWatcher = class {
4397
4738
  files = /* @__PURE__ */ new Map();
4398
4739
  directories = /* @__PURE__ */ new Map();
4399
4740
  onNewLine;
4400
4741
  onNewLines;
4742
+ onNewLineSpans;
4401
4743
  onConversationChanged;
4402
4744
  onFileDeleted;
4403
4745
  onError;
4404
4746
  constructor(events = {}) {
4405
4747
  this.onNewLine = events.onNewLine;
4406
4748
  this.onNewLines = events.onNewLines;
4749
+ this.onNewLineSpans = events.onNewLineSpans;
4407
4750
  this.onConversationChanged = events.onConversationChanged;
4408
4751
  this.onFileDeleted = events.onFileDeleted;
4409
4752
  this.onError = events.onError;
@@ -4496,20 +4839,24 @@ var ConversationWatcher = class {
4496
4839
  entry.reading = true;
4497
4840
  try {
4498
4841
  for (; ; ) {
4499
- const st = await (0, import_promises3.stat)(filePath);
4842
+ const st = await (0, import_promises5.stat)(filePath);
4500
4843
  if (st.size <= entry.offset) break;
4501
4844
  const readFrom = entry.offset;
4502
4845
  const bytesToRead = st.size - readFrom;
4503
4846
  const buf = Buffer.alloc(bytesToRead);
4504
- const fh = await (0, import_promises3.open)(filePath, "r");
4847
+ const fh = await (0, import_promises5.open)(filePath, "r");
4505
4848
  try {
4506
4849
  await fh.read(buf, 0, bytesToRead, readFrom);
4507
4850
  } finally {
4508
4851
  await fh.close();
4509
4852
  }
4510
- entry.offset = readFrom + bytesToRead;
4853
+ const { spans, consumed } = splitCompleteLines(buf, readFrom);
4854
+ entry.offset = readFrom + consumed;
4511
4855
  if (!this.files.has(filePath)) return;
4512
- const lines = buf.toString("utf-8").split("\n").filter(Boolean);
4856
+ const lines = spans.map((s) => s.text);
4857
+ if (spans.length > 0) {
4858
+ this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
4859
+ }
4513
4860
  if (this.onNewLines) {
4514
4861
  this.onNewLines(filePath, lines);
4515
4862
  } else {
@@ -4923,8 +5270,8 @@ function discoveredToResponse(d, conversationId) {
4923
5270
  }
4924
5271
 
4925
5272
  // src/uploads.ts
4926
- var import_crypto7 = require("crypto");
4927
- var import_promises4 = require("fs/promises");
5273
+ var import_crypto8 = require("crypto");
5274
+ var import_promises6 = require("fs/promises");
4928
5275
  var import_heic_convert = __toESM(require("heic-convert"), 1);
4929
5276
  var import_path12 = require("path");
4930
5277
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
@@ -4957,12 +5304,12 @@ async function saveUploadFile(input) {
4957
5304
  mimeType = "image/jpeg";
4958
5305
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
4959
5306
  }
4960
- const id = `up_${(0, import_crypto7.randomBytes)(8).toString("hex")}`;
5307
+ const id = `up_${(0, import_crypto8.randomBytes)(8).toString("hex")}`;
4961
5308
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
4962
5309
  const dir = (0, import_path12.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
4963
- await (0, import_promises4.mkdir)(dir, { recursive: true });
5310
+ await (0, import_promises6.mkdir)(dir, { recursive: true });
4964
5311
  const filePath = (0, import_path12.join)(dir, `${Date.now()}-${id}-${safeName}`);
4965
- await (0, import_promises4.writeFile)(filePath, buffer);
5312
+ await (0, import_promises6.writeFile)(filePath, buffer);
4966
5313
  return {
4967
5314
  id,
4968
5315
  filePath,
@@ -5149,6 +5496,10 @@ var StreamerServer = class {
5149
5496
  fileWatcher;
5150
5497
  sessionFileMap = /* @__PURE__ */ new Map();
5151
5498
  // sessionId → JSONL filePath
5499
+ // Per-file seq assignments from the most recent onNewLineSpans (offset index),
5500
+ // handed to the immediately-following onNewLines so it can stamp WS `seq` on
5501
+ // the matching conversation_events entries. Same read → same lines order.
5502
+ pendingLineSeqs = /* @__PURE__ */ new Map();
5152
5503
  pendingQuestions = /* @__PURE__ */ new Map();
5153
5504
  // Content key of the AskUserQuestion currently broadcast for a session (from
5154
5505
  // either the rendered screen or JSONL), used to de-dupe the two paths: when
@@ -5272,7 +5623,7 @@ var StreamerServer = class {
5272
5623
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
5273
5624
  const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
5274
5625
  if (rawRoot) {
5275
- (0, import_promises5.realpath)(rawRoot).then((resolved) => {
5626
+ (0, import_promises7.realpath)(rawRoot).then((resolved) => {
5276
5627
  this.browseRoot = resolved;
5277
5628
  if (this.verbose) this.log.info(`Browse root: ${resolved}`, { browseRoot: resolved });
5278
5629
  }).catch(() => {
@@ -5294,6 +5645,42 @@ var StreamerServer = class {
5294
5645
  this.sessionStore = new SessionStore();
5295
5646
  this.wsHub = new WSHub();
5296
5647
  this.fileWatcher = new ConversationWatcher({
5648
+ onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
5649
+ if (!this.cache) return;
5650
+ const cache = this.cache;
5651
+ this.pendingLineSeqs.delete(filePath);
5652
+ try {
5653
+ const seqs = cache.extendMessageIndex(
5654
+ filePath,
5655
+ spans,
5656
+ (0, import_fs12.statSync)(filePath),
5657
+ readFrom,
5658
+ endOffset
5659
+ );
5660
+ if (seqs === null) {
5661
+ cache.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
5662
+ cache.clearIndexParseState(filePath);
5663
+ this.trackCacheWrite(
5664
+ cache.backfillIndex(filePath).catch((err) => {
5665
+ this.log.warn("offset-index.backfill_failed", {
5666
+ event: "offset_index.backfill_failed",
5667
+ filePath,
5668
+ trigger: "noncontiguous-append",
5669
+ err
5670
+ });
5671
+ })
5672
+ );
5673
+ return;
5674
+ }
5675
+ this.pendingLineSeqs.set(filePath, seqs);
5676
+ } catch (err) {
5677
+ this.log.warn("offset-index.extend_failed", {
5678
+ event: "offset_index.extend_failed",
5679
+ filePath,
5680
+ err
5681
+ });
5682
+ }
5683
+ },
5297
5684
  onNewLines: (filePath, lines) => {
5298
5685
  this.cache?.updateFromLines(filePath, lines);
5299
5686
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
@@ -5320,13 +5707,20 @@ var StreamerServer = class {
5320
5707
  this.pendingQuestionKey.set(sessionId, key);
5321
5708
  if (broadcast) this.wsHub.broadcast(m);
5322
5709
  }
5323
- this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
5710
+ const seqs = this.pendingLineSeqs.get(filePath);
5711
+ this.wsHub.broadcast({
5712
+ type: "conversation_events",
5713
+ sessionId,
5714
+ lines,
5715
+ ...seqs && seqs.length === lines.length ? { seqs } : {}
5716
+ });
5324
5717
  for (const line of lines) {
5325
5718
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
5326
5719
  }
5327
5720
  break;
5328
5721
  }
5329
5722
  }
5723
+ this.pendingLineSeqs.delete(filePath);
5330
5724
  },
5331
5725
  onConversationChanged: (filePath) => {
5332
5726
  this.fileWatcher.poke(filePath);
@@ -6049,13 +6443,13 @@ var StreamerServer = class {
6049
6443
  }
6050
6444
  const scanner = await this.getScanner();
6051
6445
  let metas = [...scanner.getMetadataCache().values()];
6052
- metas = (0, import_scanner2.applyIncludeFilter)(metas, "conversations");
6053
- if (project) metas = (0, import_scanner2.applyProjectFilter)(metas, project);
6446
+ metas = (0, import_scanner3.applyIncludeFilter)(metas, "conversations");
6447
+ if (project) metas = (0, import_scanner3.applyProjectFilter)(metas, project);
6054
6448
  if (providerFilter)
6055
6449
  metas = metas.filter((m) => (m.provider ?? CLAUDE_CODE_PROVIDER) === providerFilter);
6056
- metas = (0, import_scanner2.applySort)(metas, sort);
6450
+ metas = (0, import_scanner3.applySort)(metas, sort);
6057
6451
  const total = metas.length;
6058
- const page = (0, import_scanner2.applyPagination)(metas, limit, offset);
6452
+ const page = (0, import_scanner3.applyPagination)(metas, limit, offset);
6059
6453
  const adapted = page.items.map((c) => {
6060
6454
  const id = c.sessionId || c.id.split("/").pop()?.replace(/\.jsonl$/, "") || c.id;
6061
6455
  return {
@@ -6099,8 +6493,8 @@ var StreamerServer = class {
6099
6493
  }
6100
6494
  const scanner = await this.getScanner(true);
6101
6495
  let metas = [...scanner.getMetadataCache().values()];
6102
- metas = (0, import_scanner2.applyIncludeFilter)(metas, "conversations");
6103
- if (project) metas = (0, import_scanner2.applyProjectFilter)(metas, project);
6496
+ metas = (0, import_scanner3.applyIncludeFilter)(metas, "conversations");
6497
+ if (project) metas = (0, import_scanner3.applyProjectFilter)(metas, project);
6104
6498
  if (providerFilter)
6105
6499
  metas = metas.filter((m) => (m.provider ?? CLAUDE_CODE_PROVIDER) === providerFilter);
6106
6500
  json(res, 200, { total: metas.length });
@@ -6197,7 +6591,7 @@ var StreamerServer = class {
6197
6591
  // reconciles the one conversation being requested, so paying a full-tree
6198
6592
  // rescan just because some OTHER file changed is the stall this avoids.
6199
6593
  newScanner(options) {
6200
- return new import_scanner2.ConversationScanner(
6594
+ return new import_scanner3.ConversationScanner(
6201
6595
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
6202
6596
  );
6203
6597
  }
@@ -6251,7 +6645,7 @@ var StreamerServer = class {
6251
6645
  if (this.scannerReady) await this.scannerReady;
6252
6646
  this.scannerStale = false;
6253
6647
  if (!this.scanner) {
6254
- this.scanner = new import_scanner2.ConversationScanner();
6648
+ this.scanner = new import_scanner3.ConversationScanner();
6255
6649
  this.allScanners.add(this.scanner);
6256
6650
  }
6257
6651
  const scanner = this.scanner;
@@ -6410,9 +6804,13 @@ var StreamerServer = class {
6410
6804
  return;
6411
6805
  }
6412
6806
  const etagSource = conversation;
6807
+ const indexedCount = etagSource.filePath && this.cache ? this.cache.getIndexedMessageCount(
6808
+ ConversationCache.conversationIdForFile(etagSource.filePath)
6809
+ ) : 0;
6810
+ const etagMessageCount = Math.max(etagSource.messageCount, indexedCount);
6413
6811
  const etag = computeConversationEtag({
6414
6812
  filePath: etagSource.filePath,
6415
- messageCount: etagSource.messageCount,
6813
+ messageCount: etagMessageCount,
6416
6814
  timestamp: etagSource.timestamp
6417
6815
  });
6418
6816
  const isFirstPage = !url.searchParams.has("before_index") && !url.searchParams.has("anchor_index") && !url.searchParams.has("after_index");
@@ -6429,12 +6827,14 @@ var StreamerServer = class {
6429
6827
  let slice = filtered;
6430
6828
  let fromIdx = 0;
6431
6829
  let messagePagination;
6830
+ let indexTotal = null;
6432
6831
  if (usePaging) {
6433
6832
  const limit = Math.min(Math.max(intParam(url, "msg_limit", 80), 1), 500);
6434
6833
  let beforeIndex = total;
6435
6834
  let scanLimit = limit;
6436
6835
  let anchorIndex = null;
6437
6836
  let newerPaging = false;
6837
+ let usedAfterIndex = false;
6438
6838
  if (url.searchParams.has("before_index")) {
6439
6839
  beforeIndex = intParam(url, "before_index", total);
6440
6840
  beforeIndex = Math.min(Math.max(beforeIndex, 0), total);
@@ -6443,6 +6843,7 @@ var StreamerServer = class {
6443
6843
  beforeIndex = Math.min(total, from + limit);
6444
6844
  scanLimit = beforeIndex - from;
6445
6845
  newerPaging = true;
6846
+ usedAfterIndex = true;
6446
6847
  } else if (hasAnchor) {
6447
6848
  anchorIndex = Math.min(
6448
6849
  Math.max(intParam(url, "anchor_index", 0), 0),
@@ -6452,9 +6853,34 @@ var StreamerServer = class {
6452
6853
  beforeIndex = Math.min(total, from + limit);
6453
6854
  newerPaging = true;
6454
6855
  }
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);
6856
+ const isTailRequest = !url.searchParams.has("before_index") && !hasAfter && !hasAnchor;
6857
+ const indexFilePath = conversation.filePath;
6858
+ if (isTailRequest && indexFilePath && this.cache) {
6859
+ const indexed = this.cache.getIndexedMessageCount(
6860
+ ConversationCache.conversationIdForFile(indexFilePath)
6861
+ );
6862
+ if (indexed > beforeIndex) {
6863
+ beforeIndex = indexed;
6864
+ }
6865
+ }
6866
+ const windowStart = Math.max(0, beforeIndex - scanLimit);
6867
+ const indexWindow = scanLimit > 0 && !hasAnchor && indexFilePath && this.cache ? this.cache.readMessageWindow(indexFilePath, windowStart, beforeIndex) : null;
6868
+ if (!indexWindow && indexFilePath && this.cache && !hasAnchor) {
6869
+ this.trackCacheWrite(
6870
+ this.cache.backfillIndex(indexFilePath).catch((err) => {
6871
+ this.log.warn("offset-index.backfill_failed", {
6872
+ event: "offset_index.backfill_failed",
6873
+ conversationId: id,
6874
+ filePath: indexFilePath,
6875
+ err
6876
+ });
6877
+ })
6878
+ );
6879
+ }
6880
+ const pagedScanner = !indexWindow && this.scannerReady ? await this.getScanner(true) : null;
6881
+ const page = indexWindow ?? (scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null);
6882
+ if (indexWindow) indexTotal = indexWindow.total;
6883
+ const start = page?.fromIndex ?? windowStart;
6458
6884
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
6459
6885
  fromIdx = start;
6460
6886
  const effectiveTotal = page?.total ?? total;
@@ -6470,6 +6896,9 @@ var StreamerServer = class {
6470
6896
  messagePagination.has_more_newer = beforeIndex < effectiveTotal;
6471
6897
  messagePagination.next_after_index = beforeIndex < effectiveTotal ? beforeIndex : null;
6472
6898
  }
6899
+ if (usedAfterIndex) {
6900
+ messagePagination.etag = etag;
6901
+ }
6473
6902
  }
6474
6903
  const messagesPayload = slice.map((m, localIdx) => {
6475
6904
  const content = [];
@@ -6511,6 +6940,8 @@ var StreamerServer = class {
6511
6940
  const cachedConvMeta = this.cache?.getMetaById(id);
6512
6941
  const convProvider = coerceProviderForRunner(conv.provider ?? cachedConvMeta?.provider);
6513
6942
  const availability = classifyResumability(conv.projectPath);
6943
+ const metaMessageCount = indexTotal != null && indexTotal > conv.messageCount ? indexTotal : conv.messageCount;
6944
+ const metaLastUpdatedAt = indexTotal != null && indexTotal > conv.messageCount ? slice.at(-1)?.timestamp ?? conv.timestamp : conv.timestamp;
6514
6945
  const body = {
6515
6946
  meta: {
6516
6947
  id,
@@ -6518,8 +6949,8 @@ var StreamerServer = class {
6518
6949
  project_name: conv.projectName,
6519
6950
  project_path: conv.projectPath,
6520
6951
  file_path: conv.filePath,
6521
- last_updated_at: conv.timestamp,
6522
- message_count: conv.messageCount,
6952
+ last_updated_at: metaLastUpdatedAt,
6953
+ message_count: metaMessageCount,
6523
6954
  last_prompt: conv.lastPrompt ?? void 0,
6524
6955
  provider: convProvider,
6525
6956
  resumable: isProviderResumable(convProvider, availability.resumable),
@@ -6611,7 +7042,7 @@ var StreamerServer = class {
6611
7042
  }
6612
7043
  const limit = intParam(url, "limit", 50);
6613
7044
  const scanner = await this.getScanner();
6614
- const results = await (0, import_scanner2.search)(
7045
+ const results = await (0, import_scanner3.search)(
6615
7046
  q,
6616
7047
  {
6617
7048
  limit,