@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.js CHANGED
@@ -3124,9 +3124,15 @@ async function createDirectory(parentAbsolutePath, name) {
3124
3124
  }
3125
3125
 
3126
3126
  // src/conversation-cache.ts
3127
+ import {
3128
+ createJsonlParseState,
3129
+ parseJsonlLine
3130
+ } from "@threadbase-sh/scanner";
3127
3131
  import Database from "better-sqlite3";
3128
3132
  import { closeSync as closeSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
3133
+ import { open as openAsync } from "fs/promises";
3129
3134
  import { dirname as dirname6 } from "path";
3135
+ import { setImmediate as yieldToEventLoop } from "timers/promises";
3130
3136
 
3131
3137
  // src/db/sqlite-migrate.ts
3132
3138
  import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
@@ -3238,6 +3244,33 @@ function parseAgentEntrypointsEnv(raw) {
3238
3244
  return new Set(parts);
3239
3245
  }
3240
3246
 
3247
+ // src/utils/fileIdentity.ts
3248
+ import { createHash } from "crypto";
3249
+ function fileIdentity(stat3, headBytes) {
3250
+ if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
3251
+ const head = headBytes ?? Buffer.alloc(0);
3252
+ return `fp:${createHash("sha1").update(head).digest("hex")}`;
3253
+ }
3254
+ function splitCompleteLines(buf, baseOffset) {
3255
+ const spans = [];
3256
+ let lineStart = 0;
3257
+ let consumed = 0;
3258
+ for (let i = 0; i < buf.length; i++) {
3259
+ if (buf[i] !== 10) continue;
3260
+ const lineLen = i - lineStart;
3261
+ if (lineLen > 0) {
3262
+ spans.push({
3263
+ byteOffset: baseOffset + lineStart,
3264
+ byteLength: lineLen,
3265
+ text: buf.toString("utf-8", lineStart, i)
3266
+ });
3267
+ }
3268
+ lineStart = i + 1;
3269
+ consumed = lineStart;
3270
+ }
3271
+ return { spans, consumed };
3272
+ }
3273
+
3241
3274
  // src/conversation-cache.ts
3242
3275
  function shortProjectName(fullPath) {
3243
3276
  const parts = fullPath.split(/[/\\]/).filter(Boolean);
@@ -3286,6 +3319,14 @@ var ConversationCache = class _ConversationCache {
3286
3319
  tailSize;
3287
3320
  fileIndex = /* @__PURE__ */ new Map();
3288
3321
  fileIndexLoaded = false;
3322
+ // Per-file scanner parse state for the incremental offset-index writer. The
3323
+ // reducer is stateful across lines (pending tool_uses, latest timestamp), so
3324
+ // it must persist between watcher reads of the same file. Cleared on
3325
+ // truncation/backfill.
3326
+ indexParseState = /* @__PURE__ */ new Map();
3327
+ // Single-flight guard for backfillIndex — concurrent detail requests for the
3328
+ // same cold file await one walk, not N. Entry dropped on settle.
3329
+ backfillInFlight = /* @__PURE__ */ new Map();
3289
3330
  // Monotonically increasing counter for tail updated_at — guarantees strict
3290
3331
  // ordering even when multiple updateFromLine() calls land within the same ms.
3291
3332
  tailSeq = Date.now();
@@ -3449,6 +3490,42 @@ var ConversationCache = class _ConversationCache {
3449
3490
  GROUP BY project_path
3450
3491
  ORDER BY cnt DESC
3451
3492
  LIMIT ?`
3493
+ ),
3494
+ getFileState: db.prepare("SELECT * FROM conversation_file_state WHERE path = ?"),
3495
+ upsertFileState: db.prepare(
3496
+ `INSERT INTO conversation_file_state
3497
+ (path, identity, size, mtime_ms, byte_offset, last_message_index)
3498
+ VALUES (@path, @identity, @size, @mtime_ms, @byte_offset, @last_message_index)
3499
+ ON CONFLICT(path) DO UPDATE SET
3500
+ identity = excluded.identity,
3501
+ size = excluded.size,
3502
+ mtime_ms = excluded.mtime_ms,
3503
+ byte_offset = excluded.byte_offset,
3504
+ last_message_index = excluded.last_message_index`
3505
+ ),
3506
+ deleteFileState: db.prepare("DELETE FROM conversation_file_state WHERE path = ?"),
3507
+ deleteMessageIndex: db.prepare(
3508
+ "DELETE FROM conversation_message_index WHERE conversation_id = ?"
3509
+ ),
3510
+ insertMessageIndexRow: db.prepare(
3511
+ `INSERT INTO conversation_message_index
3512
+ (conversation_id, message_index, byte_offset, byte_length, uuid, role, ts)
3513
+ VALUES (@conversation_id, @message_index, @byte_offset, @byte_length, @uuid, @role, @ts)
3514
+ ON CONFLICT(conversation_id, message_index) DO UPDATE SET
3515
+ byte_offset = excluded.byte_offset,
3516
+ byte_length = excluded.byte_length,
3517
+ uuid = excluded.uuid,
3518
+ role = excluded.role,
3519
+ ts = excluded.ts`
3520
+ ),
3521
+ getMessageIndexWindow: db.prepare(
3522
+ `SELECT message_index, byte_offset, byte_length, uuid, role, ts
3523
+ FROM conversation_message_index
3524
+ WHERE conversation_id = ? AND message_index >= ? AND message_index < ?
3525
+ ORDER BY message_index ASC`
3526
+ ),
3527
+ getIndexedMessageCount: db.prepare(
3528
+ "SELECT COUNT(*) as cnt FROM conversation_message_index WHERE conversation_id = ?"
3452
3529
  )
3453
3530
  };
3454
3531
  }
@@ -3459,6 +3536,273 @@ var ConversationCache = class _ConversationCache {
3459
3536
  getDatabase() {
3460
3537
  return this.db;
3461
3538
  }
3539
+ // ── Offset index (design 1b) ────────────────────────────────────────────
3540
+ // conversation_file_state + conversation_message_index back the windowed
3541
+ // detail read path (SQL window select + pread of byte ranges). All methods
3542
+ // are thin wrappers over the prepared statements above.
3543
+ getFileState(path) {
3544
+ return this.stmts.getFileState.get(path) ?? null;
3545
+ }
3546
+ upsertFileState(row) {
3547
+ this.stmts.upsertFileState.run(row);
3548
+ }
3549
+ /** Drop a file's index rows + file_state (truncation / identity change). */
3550
+ deleteFileIndex(path, conversationId) {
3551
+ const tx = this.db.transaction(() => {
3552
+ this.stmts.deleteMessageIndex.run(conversationId);
3553
+ this.stmts.deleteFileState.run(path);
3554
+ });
3555
+ tx();
3556
+ this.indexParseState.delete(path);
3557
+ }
3558
+ /** Append/replace index rows in one transaction. */
3559
+ appendMessageIndexRows(rows) {
3560
+ const tx = this.db.transaction((batch) => {
3561
+ for (const r of batch) this.stmts.insertMessageIndexRow.run(r);
3562
+ });
3563
+ tx(rows);
3564
+ }
3565
+ /** Rows for message_index in [fromIndex, toIndex), ordered ascending. */
3566
+ getMessageIndexWindow(conversationId, fromIndex, toIndex) {
3567
+ return this.stmts.getMessageIndexWindow.all(
3568
+ conversationId,
3569
+ fromIndex,
3570
+ toIndex
3571
+ );
3572
+ }
3573
+ getIndexedMessageCount(conversationId) {
3574
+ return this.stmts.getIndexedMessageCount.get(conversationId).cnt;
3575
+ }
3576
+ /**
3577
+ * Conversation id for a JSONL path — the filename stem (matches the pseudo-id
3578
+ * updateFromLine derives and the uuid the detail read path resolves). The
3579
+ * offset index keys on this so the window select and the cursor agree.
3580
+ */
3581
+ static conversationIdForFile(filePath) {
3582
+ return filePath.split(/[/\\]/).pop()?.replace(/\.jsonl$/, "") ?? filePath;
3583
+ }
3584
+ // The offset index understands only claude-code JSONL: parseJsonlLine is the
3585
+ // claude-code reducer, so a codex file "indexes" as zero messages and the
3586
+ // resulting file_state serves empty windows for a real conversation (the
3587
+ // silent-wrong-data bug hotfixed after 1.28.0). Non-claude providers are
3588
+ // excluded from the index entirely; the scanner serves them (it routes each
3589
+ // provider to its own parser).
3590
+ isIndexableFile(filePath) {
3591
+ const meta = this.getMetaById(_ConversationCache.conversationIdForFile(filePath));
3592
+ return (meta?.provider ?? CLAUDE_CODE_PROVIDER) === CLAUDE_CODE_PROVIDER;
3593
+ }
3594
+ /**
3595
+ * Incremental offset-index writer: extend the index for a burst of appended
3596
+ * lines (one watcher read) using their byte spans. Each line is classified
3597
+ * with the scanner's parseJsonlLine (a running per-file reducer state), so the
3598
+ * message ordering can never drift from the scanner's. Message lines get an
3599
+ * index row at the next message_index; non-message lines (summary/sidecar)
3600
+ * get no row but still advance byte_offset. file_state is updated to the end
3601
+ * of the last consumed span.
3602
+ *
3603
+ * Requires an up-to-date `stat` (identity/size/mtime) for the file so the read
3604
+ * path can detect truncation/replacement.
3605
+ *
3606
+ * `readFrom` is the absolute byte offset the watcher read started at, and
3607
+ * `endOffset` is where it ended (readFrom + consumed, i.e. the watcher's new
3608
+ * entry.offset). CONTIGUITY GUARD: the read must begin exactly where the index
3609
+ * left off (`readFrom === existing.byte_offset`, or 0 with no state). If it
3610
+ * doesn't — the watcher attached at EOF after the server was down, or an
3611
+ * append raced an in-flight backfill — extending would assign wrong
3612
+ * message_index values over a hole. In that case this writes nothing and
3613
+ * returns null so the caller drops the index and backfills.
3614
+ *
3615
+ * On success returns the message_index assigned to each input span (null for a
3616
+ * non-message line) so the caller can stamp WS `seq`. Empty array when spans
3617
+ * is empty. `endOffset` is stored verbatim as byte_offset so the watcher's
3618
+ * offset and file_state.byte_offset are the same number by construction.
3619
+ */
3620
+ extendMessageIndex(filePath, spans, stat3, readFrom, endOffset) {
3621
+ if (!this.isIndexableFile(filePath)) return spans.map(() => null);
3622
+ const existing = this.getFileState(filePath);
3623
+ const expectedStart = existing?.byte_offset ?? 0;
3624
+ if (readFrom !== expectedStart) return null;
3625
+ if (spans.length === 0) return [];
3626
+ const convId = _ConversationCache.conversationIdForFile(filePath);
3627
+ let state = this.indexParseState.get(filePath);
3628
+ if (!state) {
3629
+ state = createJsonlParseState();
3630
+ this.indexParseState.set(filePath, state);
3631
+ }
3632
+ let nextIndex = existing ? existing.last_message_index + 1 : 0;
3633
+ const rows = [];
3634
+ const seqs = [];
3635
+ for (const span of spans) {
3636
+ const msg = parseJsonlLine(span.text, state);
3637
+ if (!msg) {
3638
+ seqs.push(null);
3639
+ continue;
3640
+ }
3641
+ rows.push({
3642
+ conversation_id: convId,
3643
+ message_index: nextIndex,
3644
+ byte_offset: span.byteOffset,
3645
+ byte_length: span.byteLength,
3646
+ uuid: msg.uuid ?? null,
3647
+ role: msg.role ?? null,
3648
+ ts: msg.timestamp ? Date.parse(msg.timestamp) || null : null
3649
+ });
3650
+ seqs.push(nextIndex);
3651
+ nextIndex++;
3652
+ }
3653
+ const tx = this.db.transaction(() => {
3654
+ for (const r of rows) this.stmts.insertMessageIndexRow.run(r);
3655
+ this.stmts.upsertFileState.run({
3656
+ path: filePath,
3657
+ identity: fileIdentity(stat3),
3658
+ size: stat3.size,
3659
+ mtime_ms: Math.round(stat3.mtimeMs),
3660
+ // Store the watcher's end offset verbatim — same number as entry.offset,
3661
+ // so the next read's contiguity check compares like-for-like (never
3662
+ // false-positive on a read that ended in trailing empty lines).
3663
+ byte_offset: endOffset,
3664
+ last_message_index: nextIndex - 1
3665
+ });
3666
+ });
3667
+ tx();
3668
+ return seqs;
3669
+ }
3670
+ clearIndexParseState(filePath) {
3671
+ this.indexParseState.delete(filePath);
3672
+ }
3673
+ /**
3674
+ * On-demand full backfill of the offset index for a file with no/stale
3675
+ * file_state (cold conversation, or after a truncation/replacement). Rebuilds
3676
+ * from byte 0: drops any existing rows, walks the whole file in chunks with a
3677
+ * running parse state, yields to the event loop every ~1000 lines so a large
3678
+ * file never blocks, and writes index rows + file_state.
3679
+ *
3680
+ * Single-flighted per path: concurrent callers await the same walk. The
3681
+ * triggering detail request is served by the scanner fallback while this runs.
3682
+ */
3683
+ backfillIndex(filePath) {
3684
+ const inFlight = this.backfillInFlight.get(filePath);
3685
+ if (inFlight) return inFlight;
3686
+ const walk = this.runBackfill(filePath).finally(() => {
3687
+ this.backfillInFlight.delete(filePath);
3688
+ });
3689
+ this.backfillInFlight.set(filePath, walk);
3690
+ return walk;
3691
+ }
3692
+ async runBackfill(filePath) {
3693
+ const convId = _ConversationCache.conversationIdForFile(filePath);
3694
+ this.deleteFileIndex(filePath, convId);
3695
+ this.indexParseState.delete(filePath);
3696
+ if (!this.isIndexableFile(filePath)) return;
3697
+ const CHUNK = 256 * 1024;
3698
+ const YIELD_EVERY = 1e3;
3699
+ const state = createJsonlParseState();
3700
+ const fh = await openAsync(filePath, "r");
3701
+ let fileOffset = 0;
3702
+ let carry = Buffer.alloc(0);
3703
+ let nextIndex = 0;
3704
+ let linesSinceYield = 0;
3705
+ let lastConsumedEnd = 0;
3706
+ let stat3;
3707
+ try {
3708
+ stat3 = await fh.stat();
3709
+ const buf = Buffer.alloc(CHUNK);
3710
+ for (; ; ) {
3711
+ const { bytesRead } = await fh.read(buf, 0, CHUNK, null);
3712
+ if (bytesRead === 0) break;
3713
+ const combined = carry.length > 0 ? Buffer.concat([carry, buf.subarray(0, bytesRead)]) : buf.subarray(0, bytesRead);
3714
+ const { spans, consumed } = splitCompleteLines(combined, fileOffset);
3715
+ const rows = [];
3716
+ for (const span of spans) {
3717
+ const msg = parseJsonlLine(span.text, state);
3718
+ linesSinceYield++;
3719
+ if (msg) {
3720
+ rows.push({
3721
+ conversation_id: convId,
3722
+ message_index: nextIndex,
3723
+ byte_offset: span.byteOffset,
3724
+ byte_length: span.byteLength,
3725
+ uuid: msg.uuid ?? null,
3726
+ role: msg.role ?? null,
3727
+ ts: msg.timestamp ? Date.parse(msg.timestamp) || null : null
3728
+ });
3729
+ nextIndex++;
3730
+ }
3731
+ if (linesSinceYield >= YIELD_EVERY) {
3732
+ linesSinceYield = 0;
3733
+ await yieldToEventLoop();
3734
+ }
3735
+ }
3736
+ if (rows.length > 0) this.appendMessageIndexRows(rows);
3737
+ lastConsumedEnd = fileOffset + consumed;
3738
+ carry = Buffer.from(combined.subarray(consumed));
3739
+ fileOffset += consumed;
3740
+ }
3741
+ } finally {
3742
+ await fh.close();
3743
+ }
3744
+ this.upsertFileState({
3745
+ path: filePath,
3746
+ identity: fileIdentity(stat3),
3747
+ size: stat3.size,
3748
+ mtime_ms: Math.round(stat3.mtimeMs),
3749
+ byte_offset: lastConsumedEnd,
3750
+ last_message_index: nextIndex - 1
3751
+ });
3752
+ this.indexParseState.set(filePath, state);
3753
+ }
3754
+ /**
3755
+ * Windowed detail read straight from the offset index — the hot path.
3756
+ * Returns the parsed messages for message_index in [fromIndex, toIndex) plus
3757
+ * the total indexed count, or null when the index can't serve this file (no
3758
+ * file_state, identity/size mismatch = truncation/replacement, or cold index)
3759
+ * so the caller falls back to the scanner and enqueues a backfill.
3760
+ *
3761
+ * On a match it SQL-selects the window's byte ranges and preads exactly those
3762
+ * ranges from the JSONL (never the whole file), parsing only the sliced lines.
3763
+ * Returns messages in the same ConversationMessage shape parseJsonlLine
3764
+ * produces during a scan, so the payload is identical to the scanner path.
3765
+ */
3766
+ readMessageWindow(filePath, fromIndex, toIndex) {
3767
+ const fileState = this.getFileState(filePath);
3768
+ if (!fileState) return null;
3769
+ let stat3;
3770
+ try {
3771
+ stat3 = statSync2(filePath);
3772
+ } catch {
3773
+ return null;
3774
+ }
3775
+ if (fileIdentity(stat3) !== fileState.identity || stat3.size !== fileState.byte_offset) {
3776
+ return null;
3777
+ }
3778
+ if (fileState.last_message_index < 0 || !this.isIndexableFile(filePath)) {
3779
+ return null;
3780
+ }
3781
+ const total = fileState.last_message_index + 1;
3782
+ const from = Math.max(0, fromIndex);
3783
+ const to = Math.min(toIndex, total);
3784
+ if (to <= from) return { messages: [], total, fromIndex: from };
3785
+ const rows = this.getMessageIndexWindow(
3786
+ _ConversationCache.conversationIdForFile(filePath),
3787
+ from,
3788
+ to
3789
+ );
3790
+ if (rows.length === 0) return { messages: [], total, fromIndex: from };
3791
+ const messages = [];
3792
+ const fd = openSync2(filePath, "r");
3793
+ try {
3794
+ const state = createJsonlParseState();
3795
+ for (const row of rows) {
3796
+ const buf = Buffer.alloc(row.byte_length);
3797
+ readSync2(fd, buf, 0, row.byte_length, row.byte_offset);
3798
+ const msg = parseJsonlLine(buf.toString("utf-8"), state);
3799
+ if (msg) messages.push(msg);
3800
+ }
3801
+ } finally {
3802
+ closeSync2(fd);
3803
+ }
3804
+ return { messages, total, fromIndex: from };
3805
+ }
3462
3806
  agentEntrypointsKey() {
3463
3807
  return [...this.agentEntrypoints].sort().join(",");
3464
3808
  }
@@ -4359,12 +4703,14 @@ var ConversationWatcher = class {
4359
4703
  directories = /* @__PURE__ */ new Map();
4360
4704
  onNewLine;
4361
4705
  onNewLines;
4706
+ onNewLineSpans;
4362
4707
  onConversationChanged;
4363
4708
  onFileDeleted;
4364
4709
  onError;
4365
4710
  constructor(events = {}) {
4366
4711
  this.onNewLine = events.onNewLine;
4367
4712
  this.onNewLines = events.onNewLines;
4713
+ this.onNewLineSpans = events.onNewLineSpans;
4368
4714
  this.onConversationChanged = events.onConversationChanged;
4369
4715
  this.onFileDeleted = events.onFileDeleted;
4370
4716
  this.onError = events.onError;
@@ -4468,9 +4814,13 @@ var ConversationWatcher = class {
4468
4814
  } finally {
4469
4815
  await fh.close();
4470
4816
  }
4471
- entry.offset = readFrom + bytesToRead;
4817
+ const { spans, consumed } = splitCompleteLines(buf, readFrom);
4818
+ entry.offset = readFrom + consumed;
4472
4819
  if (!this.files.has(filePath)) return;
4473
- const lines = buf.toString("utf-8").split("\n").filter(Boolean);
4820
+ const lines = spans.map((s) => s.text);
4821
+ if (spans.length > 0) {
4822
+ this.onNewLineSpans?.(filePath, spans, readFrom, entry.offset);
4823
+ }
4474
4824
  if (this.onNewLines) {
4475
4825
  this.onNewLines(filePath, lines);
4476
4826
  } else {
@@ -4939,13 +5289,13 @@ function sanitizeFilename(name) {
4939
5289
  }
4940
5290
 
4941
5291
  // src/utils/conversationEtag.ts
4942
- import { createHash } from "crypto";
5292
+ import { createHash as createHash2 } from "crypto";
4943
5293
  function computeConversationEtag({
4944
5294
  filePath,
4945
5295
  messageCount,
4946
5296
  timestamp
4947
5297
  }) {
4948
- const digest = createHash("sha1").update(`${filePath}:${messageCount}:${timestamp}`).digest("hex").slice(0, 16);
5298
+ const digest = createHash2("sha1").update(`${filePath}:${messageCount}:${timestamp}`).digest("hex").slice(0, 16);
4949
5299
  return `"${digest}"`;
4950
5300
  }
4951
5301
 
@@ -5110,6 +5460,10 @@ var StreamerServer = class {
5110
5460
  fileWatcher;
5111
5461
  sessionFileMap = /* @__PURE__ */ new Map();
5112
5462
  // sessionId → JSONL filePath
5463
+ // Per-file seq assignments from the most recent onNewLineSpans (offset index),
5464
+ // handed to the immediately-following onNewLines so it can stamp WS `seq` on
5465
+ // the matching conversation_events entries. Same read → same lines order.
5466
+ pendingLineSeqs = /* @__PURE__ */ new Map();
5113
5467
  pendingQuestions = /* @__PURE__ */ new Map();
5114
5468
  // Content key of the AskUserQuestion currently broadcast for a session (from
5115
5469
  // either the rendered screen or JSONL), used to de-dupe the two paths: when
@@ -5255,6 +5609,42 @@ var StreamerServer = class {
5255
5609
  this.sessionStore = new SessionStore();
5256
5610
  this.wsHub = new WSHub();
5257
5611
  this.fileWatcher = new ConversationWatcher({
5612
+ onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
5613
+ if (!this.cache) return;
5614
+ const cache = this.cache;
5615
+ this.pendingLineSeqs.delete(filePath);
5616
+ try {
5617
+ const seqs = cache.extendMessageIndex(
5618
+ filePath,
5619
+ spans,
5620
+ statSync5(filePath),
5621
+ readFrom,
5622
+ endOffset
5623
+ );
5624
+ if (seqs === null) {
5625
+ cache.deleteFileIndex(filePath, ConversationCache.conversationIdForFile(filePath));
5626
+ cache.clearIndexParseState(filePath);
5627
+ this.trackCacheWrite(
5628
+ cache.backfillIndex(filePath).catch((err) => {
5629
+ this.log.warn("offset-index.backfill_failed", {
5630
+ event: "offset_index.backfill_failed",
5631
+ filePath,
5632
+ trigger: "noncontiguous-append",
5633
+ err
5634
+ });
5635
+ })
5636
+ );
5637
+ return;
5638
+ }
5639
+ this.pendingLineSeqs.set(filePath, seqs);
5640
+ } catch (err) {
5641
+ this.log.warn("offset-index.extend_failed", {
5642
+ event: "offset_index.extend_failed",
5643
+ filePath,
5644
+ err
5645
+ });
5646
+ }
5647
+ },
5258
5648
  onNewLines: (filePath, lines) => {
5259
5649
  this.cache?.updateFromLines(filePath, lines);
5260
5650
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
@@ -5281,13 +5671,20 @@ var StreamerServer = class {
5281
5671
  this.pendingQuestionKey.set(sessionId, key);
5282
5672
  if (broadcast) this.wsHub.broadcast(m);
5283
5673
  }
5284
- this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
5674
+ const seqs = this.pendingLineSeqs.get(filePath);
5675
+ this.wsHub.broadcast({
5676
+ type: "conversation_events",
5677
+ sessionId,
5678
+ lines,
5679
+ ...seqs && seqs.length === lines.length ? { seqs } : {}
5680
+ });
5285
5681
  for (const line of lines) {
5286
5682
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
5287
5683
  }
5288
5684
  break;
5289
5685
  }
5290
5686
  }
5687
+ this.pendingLineSeqs.delete(filePath);
5291
5688
  },
5292
5689
  onConversationChanged: (filePath) => {
5293
5690
  this.fileWatcher.poke(filePath);
@@ -6371,9 +6768,13 @@ var StreamerServer = class {
6371
6768
  return;
6372
6769
  }
6373
6770
  const etagSource = conversation;
6771
+ const indexedCount = etagSource.filePath && this.cache ? this.cache.getIndexedMessageCount(
6772
+ ConversationCache.conversationIdForFile(etagSource.filePath)
6773
+ ) : 0;
6774
+ const etagMessageCount = Math.max(etagSource.messageCount, indexedCount);
6374
6775
  const etag = computeConversationEtag({
6375
6776
  filePath: etagSource.filePath,
6376
- messageCount: etagSource.messageCount,
6777
+ messageCount: etagMessageCount,
6377
6778
  timestamp: etagSource.timestamp
6378
6779
  });
6379
6780
  const isFirstPage = !url.searchParams.has("before_index") && !url.searchParams.has("anchor_index") && !url.searchParams.has("after_index");
@@ -6390,12 +6791,14 @@ var StreamerServer = class {
6390
6791
  let slice = filtered;
6391
6792
  let fromIdx = 0;
6392
6793
  let messagePagination;
6794
+ let indexTotal = null;
6393
6795
  if (usePaging) {
6394
6796
  const limit = Math.min(Math.max(intParam(url, "msg_limit", 80), 1), 500);
6395
6797
  let beforeIndex = total;
6396
6798
  let scanLimit = limit;
6397
6799
  let anchorIndex = null;
6398
6800
  let newerPaging = false;
6801
+ let usedAfterIndex = false;
6399
6802
  if (url.searchParams.has("before_index")) {
6400
6803
  beforeIndex = intParam(url, "before_index", total);
6401
6804
  beforeIndex = Math.min(Math.max(beforeIndex, 0), total);
@@ -6404,6 +6807,7 @@ var StreamerServer = class {
6404
6807
  beforeIndex = Math.min(total, from + limit);
6405
6808
  scanLimit = beforeIndex - from;
6406
6809
  newerPaging = true;
6810
+ usedAfterIndex = true;
6407
6811
  } else if (hasAnchor) {
6408
6812
  anchorIndex = Math.min(
6409
6813
  Math.max(intParam(url, "anchor_index", 0), 0),
@@ -6413,9 +6817,34 @@ var StreamerServer = class {
6413
6817
  beforeIndex = Math.min(total, from + limit);
6414
6818
  newerPaging = true;
6415
6819
  }
6416
- const pagedScanner = this.scannerReady ? await this.getScanner(true) : null;
6417
- const page = scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null;
6418
- const start = page?.fromIndex ?? Math.max(0, beforeIndex - scanLimit);
6820
+ const isTailRequest = !url.searchParams.has("before_index") && !hasAfter && !hasAnchor;
6821
+ const indexFilePath = conversation.filePath;
6822
+ if (isTailRequest && indexFilePath && this.cache) {
6823
+ const indexed = this.cache.getIndexedMessageCount(
6824
+ ConversationCache.conversationIdForFile(indexFilePath)
6825
+ );
6826
+ if (indexed > beforeIndex) {
6827
+ beforeIndex = indexed;
6828
+ }
6829
+ }
6830
+ const windowStart = Math.max(0, beforeIndex - scanLimit);
6831
+ const indexWindow = scanLimit > 0 && !hasAnchor && indexFilePath && this.cache ? this.cache.readMessageWindow(indexFilePath, windowStart, beforeIndex) : null;
6832
+ if (!indexWindow && indexFilePath && this.cache && !hasAnchor) {
6833
+ this.trackCacheWrite(
6834
+ this.cache.backfillIndex(indexFilePath).catch((err) => {
6835
+ this.log.warn("offset-index.backfill_failed", {
6836
+ event: "offset_index.backfill_failed",
6837
+ conversationId: id,
6838
+ filePath: indexFilePath,
6839
+ err
6840
+ });
6841
+ })
6842
+ );
6843
+ }
6844
+ const pagedScanner = !indexWindow && this.scannerReady ? await this.getScanner(true) : null;
6845
+ const page = indexWindow ?? (scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null);
6846
+ if (indexWindow) indexTotal = indexWindow.total;
6847
+ const start = page?.fromIndex ?? windowStart;
6419
6848
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
6420
6849
  fromIdx = start;
6421
6850
  const effectiveTotal = page?.total ?? total;
@@ -6431,6 +6860,9 @@ var StreamerServer = class {
6431
6860
  messagePagination.has_more_newer = beforeIndex < effectiveTotal;
6432
6861
  messagePagination.next_after_index = beforeIndex < effectiveTotal ? beforeIndex : null;
6433
6862
  }
6863
+ if (usedAfterIndex) {
6864
+ messagePagination.etag = etag;
6865
+ }
6434
6866
  }
6435
6867
  const messagesPayload = slice.map((m, localIdx) => {
6436
6868
  const content = [];
@@ -6472,6 +6904,8 @@ var StreamerServer = class {
6472
6904
  const cachedConvMeta = this.cache?.getMetaById(id);
6473
6905
  const convProvider = coerceProviderForRunner(conv.provider ?? cachedConvMeta?.provider);
6474
6906
  const availability = classifyResumability(conv.projectPath);
6907
+ const metaMessageCount = indexTotal != null && indexTotal > conv.messageCount ? indexTotal : conv.messageCount;
6908
+ const metaLastUpdatedAt = indexTotal != null && indexTotal > conv.messageCount ? slice.at(-1)?.timestamp ?? conv.timestamp : conv.timestamp;
6475
6909
  const body = {
6476
6910
  meta: {
6477
6911
  id,
@@ -6479,8 +6913,8 @@ var StreamerServer = class {
6479
6913
  project_name: conv.projectName,
6480
6914
  project_path: conv.projectPath,
6481
6915
  file_path: conv.filePath,
6482
- last_updated_at: conv.timestamp,
6483
- message_count: conv.messageCount,
6916
+ last_updated_at: metaLastUpdatedAt,
6917
+ message_count: metaMessageCount,
6484
6918
  last_prompt: conv.lastPrompt ?? void 0,
6485
6919
  provider: convProvider,
6486
6920
  resumable: isProviderResumable(convProvider, availability.resumable),