@threadbase-sh/streamer 1.27.2 → 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,
@@ -5134,6 +5466,7 @@ var WSHub = class {
5134
5466
  var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project boundary: ${browseRoot}. Do not read, write, or execute commands that access files or directories outside this boundary.`;
5135
5467
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5136
5468
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5469
+ var REFRESH_TTL_MS = 2e3;
5137
5470
  var START_READY_TIMEOUT_MS = 15e3;
5138
5471
  function parseIncludeAgentsEnv(raw) {
5139
5472
  if (raw === void 0) return false;
@@ -5148,6 +5481,10 @@ var StreamerServer = class {
5148
5481
  fileWatcher;
5149
5482
  sessionFileMap = /* @__PURE__ */ new Map();
5150
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();
5151
5488
  pendingQuestions = /* @__PURE__ */ new Map();
5152
5489
  // Content key of the AskUserQuestion currently broadcast for a session (from
5153
5490
  // either the rendered screen or JSONL), used to de-dupe the two paths: when
@@ -5173,6 +5510,12 @@ var StreamerServer = class {
5173
5510
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
5174
5511
  // a single rescan after the current one completes instead of restarting it.
5175
5512
  scannerStale = false;
5513
+ // Single-flight + TTL guard around scanner.refreshFile (see refreshFileGuarded).
5514
+ // A live file's mtime is always newer than the snapshot, so an unguarded
5515
+ // refresh fires on every request and re-parses the whole file from byte 0.
5516
+ // Keyed by filePath; entries drop on settle + TTL expiry, so the map stays
5517
+ // bounded by the active-file set.
5518
+ refreshInFlight = /* @__PURE__ */ new Map();
5176
5519
  // True only while bindWithRetry is actively retrying. The persistent
5177
5520
  // listener-level 'error' handler demotes EADDRINUSE to debug during this
5178
5521
  // window so the self-healing kickstart-relaunch race doesn't spam warn.
@@ -5265,7 +5608,7 @@ var StreamerServer = class {
5265
5608
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
5266
5609
  const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
5267
5610
  if (rawRoot) {
5268
- (0, import_promises5.realpath)(rawRoot).then((resolved) => {
5611
+ (0, import_promises7.realpath)(rawRoot).then((resolved) => {
5269
5612
  this.browseRoot = resolved;
5270
5613
  if (this.verbose) this.log.info(`Browse root: ${resolved}`, { browseRoot: resolved });
5271
5614
  }).catch(() => {
@@ -5287,6 +5630,42 @@ var StreamerServer = class {
5287
5630
  this.sessionStore = new SessionStore();
5288
5631
  this.wsHub = new WSHub();
5289
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
+ },
5290
5669
  onNewLines: (filePath, lines) => {
5291
5670
  this.cache?.updateFromLines(filePath, lines);
5292
5671
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
@@ -5313,13 +5692,20 @@ var StreamerServer = class {
5313
5692
  this.pendingQuestionKey.set(sessionId, key);
5314
5693
  if (broadcast) this.wsHub.broadcast(m);
5315
5694
  }
5316
- 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
+ });
5317
5702
  for (const line of lines) {
5318
5703
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
5319
5704
  }
5320
5705
  break;
5321
5706
  }
5322
5707
  }
5708
+ this.pendingLineSeqs.delete(filePath);
5323
5709
  },
5324
5710
  onConversationChanged: (filePath) => {
5325
5711
  this.fileWatcher.poke(filePath);
@@ -5371,7 +5757,7 @@ var StreamerServer = class {
5371
5757
  if (session.status === "waiting_input" || session.status === "idle") {
5372
5758
  const filePath = this.sessionFileMap.get(session.id);
5373
5759
  if (filePath) {
5374
- this.getScanner().then((scanner) => scanner.refreshFile(filePath)).then((meta) => {
5760
+ this.getScanner().then((scanner) => this.refreshFileGuarded(scanner, filePath)).then((meta) => {
5375
5761
  this.log.info("scanner.refreshFile: ok", {
5376
5762
  event: "scanner.refresh",
5377
5763
  sessionId: session.id,
@@ -5820,6 +6206,34 @@ var StreamerServer = class {
5820
6206
  this.inFlightCacheWrites.delete(guarded);
5821
6207
  });
5822
6208
  }
6209
+ // Single-flight + TTL wrapper for scanner.refreshFile. Both call sites (the
6210
+ // detail-stale branch and the per-turn refresh) route through here so that
6211
+ // N stacked retries on a live, actively-appended file cost one parse, not N:
6212
+ // - a refresh already in flight for the path → await the same promise;
6213
+ // - a refresh that settled within REFRESH_TTL_MS → skip, return null (the
6214
+ // caller keeps serving the current snapshot);
6215
+ // - otherwise start one, cache the promise, and stamp completedAt on settle.
6216
+ // The map is bounded by the active-file set (entries only live while a
6217
+ // refresh is in flight or within its TTL and are overwritten on the next
6218
+ // refresh of the same path).
6219
+ refreshFileGuarded(scanner, filePath) {
6220
+ const existing = this.refreshInFlight.get(filePath);
6221
+ if (existing) {
6222
+ const settled = existing.completedAt > 0;
6223
+ if (!settled) {
6224
+ return existing.promise;
6225
+ }
6226
+ if (Date.now() - existing.completedAt < REFRESH_TTL_MS) {
6227
+ return Promise.resolve(null);
6228
+ }
6229
+ }
6230
+ const entry = { promise: Promise.resolve(null), completedAt: 0 };
6231
+ entry.promise = scanner.refreshFile(filePath).finally(() => {
6232
+ entry.completedAt = Date.now();
6233
+ });
6234
+ this.refreshInFlight.set(filePath, entry);
6235
+ return entry.promise;
6236
+ }
5823
6237
  async close() {
5824
6238
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
5825
6239
  this.ptyGraceTimers.clear();
@@ -6014,13 +6428,13 @@ var StreamerServer = class {
6014
6428
  }
6015
6429
  const scanner = await this.getScanner();
6016
6430
  let metas = [...scanner.getMetadataCache().values()];
6017
- metas = (0, import_scanner2.applyIncludeFilter)(metas, "conversations");
6018
- 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);
6019
6433
  if (providerFilter)
6020
6434
  metas = metas.filter((m) => (m.provider ?? CLAUDE_CODE_PROVIDER) === providerFilter);
6021
- metas = (0, import_scanner2.applySort)(metas, sort);
6435
+ metas = (0, import_scanner3.applySort)(metas, sort);
6022
6436
  const total = metas.length;
6023
- const page = (0, import_scanner2.applyPagination)(metas, limit, offset);
6437
+ const page = (0, import_scanner3.applyPagination)(metas, limit, offset);
6024
6438
  const adapted = page.items.map((c) => {
6025
6439
  const id = c.sessionId || c.id.split("/").pop()?.replace(/\.jsonl$/, "") || c.id;
6026
6440
  return {
@@ -6064,8 +6478,8 @@ var StreamerServer = class {
6064
6478
  }
6065
6479
  const scanner = await this.getScanner(true);
6066
6480
  let metas = [...scanner.getMetadataCache().values()];
6067
- metas = (0, import_scanner2.applyIncludeFilter)(metas, "conversations");
6068
- 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);
6069
6483
  if (providerFilter)
6070
6484
  metas = metas.filter((m) => (m.provider ?? CLAUDE_CODE_PROVIDER) === providerFilter);
6071
6485
  json(res, 200, { total: metas.length });
@@ -6162,7 +6576,7 @@ var StreamerServer = class {
6162
6576
  // reconciles the one conversation being requested, so paying a full-tree
6163
6577
  // rescan just because some OTHER file changed is the stall this avoids.
6164
6578
  newScanner(options) {
6165
- return new import_scanner2.ConversationScanner(
6579
+ return new import_scanner3.ConversationScanner(
6166
6580
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
6167
6581
  );
6168
6582
  }
@@ -6216,7 +6630,7 @@ var StreamerServer = class {
6216
6630
  if (this.scannerReady) await this.scannerReady;
6217
6631
  this.scannerStale = false;
6218
6632
  if (!this.scanner) {
6219
- this.scanner = new import_scanner2.ConversationScanner();
6633
+ this.scanner = new import_scanner3.ConversationScanner();
6220
6634
  this.allScanners.add(this.scanner);
6221
6635
  }
6222
6636
  const scanner = this.scanner;
@@ -6284,10 +6698,22 @@ var StreamerServer = class {
6284
6698
  const scanner = await this.getScanner(true);
6285
6699
  const fromIndex = await scanner.getConversation(uuid);
6286
6700
  if (fromIndex) {
6701
+ if (this.ptyManager.hasSession(uuid)) {
6702
+ return fromIndex;
6703
+ }
6287
6704
  if (fromIndex.filePath && this.isConversationSnapshotStale(fromIndex)) {
6288
- const refreshedMeta = await scanner.refreshFile(fromIndex.filePath);
6289
- if (!refreshedMeta) return null;
6290
- return await scanner.getConversation(uuid) ?? fromIndex;
6705
+ const filePath2 = fromIndex.filePath;
6706
+ this.trackCacheWrite(
6707
+ this.refreshFileGuarded(scanner, filePath2).catch((err) => {
6708
+ this.log.warn("scanner.refreshFile: failed", {
6709
+ event: "scanner.refresh_failed",
6710
+ conversationId: uuid,
6711
+ filePath: filePath2,
6712
+ trigger: "detail-swr",
6713
+ err
6714
+ });
6715
+ })
6716
+ );
6291
6717
  }
6292
6718
  return fromIndex;
6293
6719
  }
@@ -6363,9 +6789,13 @@ var StreamerServer = class {
6363
6789
  return;
6364
6790
  }
6365
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);
6366
6796
  const etag = computeConversationEtag({
6367
6797
  filePath: etagSource.filePath,
6368
- messageCount: etagSource.messageCount,
6798
+ messageCount: etagMessageCount,
6369
6799
  timestamp: etagSource.timestamp
6370
6800
  });
6371
6801
  const isFirstPage = !url.searchParams.has("before_index") && !url.searchParams.has("anchor_index") && !url.searchParams.has("after_index");
@@ -6382,12 +6812,14 @@ var StreamerServer = class {
6382
6812
  let slice = filtered;
6383
6813
  let fromIdx = 0;
6384
6814
  let messagePagination;
6815
+ let indexTotal = null;
6385
6816
  if (usePaging) {
6386
6817
  const limit = Math.min(Math.max(intParam(url, "msg_limit", 80), 1), 500);
6387
6818
  let beforeIndex = total;
6388
6819
  let scanLimit = limit;
6389
6820
  let anchorIndex = null;
6390
6821
  let newerPaging = false;
6822
+ let usedAfterIndex = false;
6391
6823
  if (url.searchParams.has("before_index")) {
6392
6824
  beforeIndex = intParam(url, "before_index", total);
6393
6825
  beforeIndex = Math.min(Math.max(beforeIndex, 0), total);
@@ -6396,6 +6828,7 @@ var StreamerServer = class {
6396
6828
  beforeIndex = Math.min(total, from + limit);
6397
6829
  scanLimit = beforeIndex - from;
6398
6830
  newerPaging = true;
6831
+ usedAfterIndex = true;
6399
6832
  } else if (hasAnchor) {
6400
6833
  anchorIndex = Math.min(
6401
6834
  Math.max(intParam(url, "anchor_index", 0), 0),
@@ -6405,9 +6838,34 @@ var StreamerServer = class {
6405
6838
  beforeIndex = Math.min(total, from + limit);
6406
6839
  newerPaging = true;
6407
6840
  }
6408
- const pagedScanner = this.scannerReady ? await this.getScanner(true) : null;
6409
- const page = scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null;
6410
- 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;
6411
6869
  slice = page?.messages ?? filtered.slice(start, beforeIndex);
6412
6870
  fromIdx = start;
6413
6871
  const effectiveTotal = page?.total ?? total;
@@ -6423,6 +6881,9 @@ var StreamerServer = class {
6423
6881
  messagePagination.has_more_newer = beforeIndex < effectiveTotal;
6424
6882
  messagePagination.next_after_index = beforeIndex < effectiveTotal ? beforeIndex : null;
6425
6883
  }
6884
+ if (usedAfterIndex) {
6885
+ messagePagination.etag = etag;
6886
+ }
6426
6887
  }
6427
6888
  const messagesPayload = slice.map((m, localIdx) => {
6428
6889
  const content = [];
@@ -6464,6 +6925,8 @@ var StreamerServer = class {
6464
6925
  const cachedConvMeta = this.cache?.getMetaById(id);
6465
6926
  const convProvider = coerceProviderForRunner(conv.provider ?? cachedConvMeta?.provider);
6466
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;
6467
6930
  const body = {
6468
6931
  meta: {
6469
6932
  id,
@@ -6471,8 +6934,8 @@ var StreamerServer = class {
6471
6934
  project_name: conv.projectName,
6472
6935
  project_path: conv.projectPath,
6473
6936
  file_path: conv.filePath,
6474
- last_updated_at: conv.timestamp,
6475
- message_count: conv.messageCount,
6937
+ last_updated_at: metaLastUpdatedAt,
6938
+ message_count: metaMessageCount,
6476
6939
  last_prompt: conv.lastPrompt ?? void 0,
6477
6940
  provider: convProvider,
6478
6941
  resumable: isProviderResumable(convProvider, availability.resumable),
@@ -6564,7 +7027,7 @@ var StreamerServer = class {
6564
7027
  }
6565
7028
  const limit = intParam(url, "limit", 50);
6566
7029
  const scanner = await this.getScanner();
6567
- const results = await (0, import_scanner2.search)(
7030
+ const results = await (0, import_scanner3.search)(
6568
7031
  q,
6569
7032
  {
6570
7033
  limit,