@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/cli.cjs +636 -156
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +500 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +118 -1
- package/dist/index.d.ts +118 -1
- package/dist/index.js +481 -15
- package/dist/index.js.map +1 -1
- package/dist/migrations/009_create_offset_index.sql +28 -0
- package/package.json +2 -2
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
|
|
2320
|
+
var import_scanner3 = require("@threadbase-sh/scanner");
|
|
2321
2321
|
var import_events = require("events");
|
|
2322
2322
|
var import_fs12 = require("fs");
|
|
2323
|
-
var
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
4838
|
+
const { spans, consumed } = splitCompleteLines(buf, readFrom);
|
|
4839
|
+
entry.offset = readFrom + consumed;
|
|
4511
4840
|
if (!this.files.has(filePath)) return;
|
|
4512
|
-
const lines =
|
|
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
|
|
4927
|
-
var
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|
|
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) =>
|
|
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,
|
|
6018
|
-
if (project) metas = (0,
|
|
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,
|
|
6435
|
+
metas = (0, import_scanner3.applySort)(metas, sort);
|
|
6022
6436
|
const total = metas.length;
|
|
6023
|
-
const page = (0,
|
|
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,
|
|
6068
|
-
if (project) metas = (0,
|
|
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
|
|
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
|
|
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
|
|
6289
|
-
|
|
6290
|
-
|
|
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:
|
|
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
|
|
6409
|
-
const
|
|
6410
|
-
|
|
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:
|
|
6475
|
-
message_count:
|
|
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,
|
|
7030
|
+
const results = await (0, import_scanner3.search)(
|
|
6568
7031
|
q,
|
|
6569
7032
|
{
|
|
6570
7033
|
limit,
|