@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.d.cts CHANGED
@@ -6,8 +6,9 @@ import { Logger as Logger$1 } from 'pino';
6
6
  import { HttpBindings } from '@hono/node-server';
7
7
  import { ServerResponse, IncomingMessage } from 'http';
8
8
  import { WebSocket } from 'ws';
9
- import { FileStatEntry, ConversationMeta } from '@threadbase-sh/scanner';
9
+ import { ConversationMessage, FileStatEntry, ConversationMeta } from '@threadbase-sh/scanner';
10
10
  import Database from 'better-sqlite3';
11
+ import { Stats } from 'fs';
11
12
  import { z } from 'zod';
12
13
  import { Pool } from 'pg';
13
14
 
@@ -190,6 +191,7 @@ type WSMessage = {
190
191
  type: "conversation_events";
191
192
  sessionId: string;
192
193
  lines: string[];
194
+ seqs?: (number | null)[];
193
195
  } | {
194
196
  type: "question";
195
197
  sessionId: string;
@@ -367,6 +369,15 @@ interface SessionRunner {
367
369
  dispose(): void;
368
370
  }
369
371
 
372
+ interface LineSpan {
373
+ /** Absolute byte offset of the line's first byte in the file. */
374
+ byteOffset: number;
375
+ /** Byte length of the line content, excluding the trailing "\n". */
376
+ byteLength: number;
377
+ /** The decoded line text (no trailing "\n"). */
378
+ text: string;
379
+ }
380
+
370
381
  interface ConversationCacheOptions {
371
382
  filterAgentConversations?: boolean;
372
383
  agentEntrypoints?: ReadonlySet<string>;
@@ -401,6 +412,25 @@ interface CachedTail {
401
412
  messages: CachedTailMessage[];
402
413
  tailSize: number;
403
414
  }
415
+ /** A row of `conversation_file_state` — per-file offset-index resume state. */
416
+ interface FileStateRow {
417
+ path: string;
418
+ identity: string;
419
+ size: number;
420
+ mtime_ms: number;
421
+ byte_offset: number;
422
+ last_message_index: number;
423
+ }
424
+ /** A row of `conversation_message_index` — one indexed message's byte span. */
425
+ interface MessageIndexRow {
426
+ conversation_id: string;
427
+ message_index: number;
428
+ byte_offset: number;
429
+ byte_length: number;
430
+ uuid: string | null;
431
+ role: string | null;
432
+ ts: number | null;
433
+ }
404
434
  interface ScannerMeta {
405
435
  id: string;
406
436
  sessionId?: string;
@@ -423,6 +453,8 @@ declare class ConversationCache {
423
453
  private tailSize;
424
454
  private fileIndex;
425
455
  private fileIndexLoaded;
456
+ private indexParseState;
457
+ private backfillInFlight;
426
458
  private tailSeq;
427
459
  private nameSeq;
428
460
  private stmts;
@@ -436,6 +468,79 @@ declare class ConversationCache {
436
468
  * share the same connection. Internal API; not part of the public surface.
437
469
  */
438
470
  getDatabase(): Database.Database;
471
+ getFileState(path: string): FileStateRow | null;
472
+ upsertFileState(row: FileStateRow): void;
473
+ /** Drop a file's index rows + file_state (truncation / identity change). */
474
+ deleteFileIndex(path: string, conversationId: string): void;
475
+ /** Append/replace index rows in one transaction. */
476
+ appendMessageIndexRows(rows: MessageIndexRow[]): void;
477
+ /** Rows for message_index in [fromIndex, toIndex), ordered ascending. */
478
+ getMessageIndexWindow(conversationId: string, fromIndex: number, toIndex: number): MessageIndexRow[];
479
+ getIndexedMessageCount(conversationId: string): number;
480
+ /**
481
+ * Conversation id for a JSONL path — the filename stem (matches the pseudo-id
482
+ * updateFromLine derives and the uuid the detail read path resolves). The
483
+ * offset index keys on this so the window select and the cursor agree.
484
+ */
485
+ static conversationIdForFile(filePath: string): string;
486
+ private isIndexableFile;
487
+ /**
488
+ * Incremental offset-index writer: extend the index for a burst of appended
489
+ * lines (one watcher read) using their byte spans. Each line is classified
490
+ * with the scanner's parseJsonlLine (a running per-file reducer state), so the
491
+ * message ordering can never drift from the scanner's. Message lines get an
492
+ * index row at the next message_index; non-message lines (summary/sidecar)
493
+ * get no row but still advance byte_offset. file_state is updated to the end
494
+ * of the last consumed span.
495
+ *
496
+ * Requires an up-to-date `stat` (identity/size/mtime) for the file so the read
497
+ * path can detect truncation/replacement.
498
+ *
499
+ * `readFrom` is the absolute byte offset the watcher read started at, and
500
+ * `endOffset` is where it ended (readFrom + consumed, i.e. the watcher's new
501
+ * entry.offset). CONTIGUITY GUARD: the read must begin exactly where the index
502
+ * left off (`readFrom === existing.byte_offset`, or 0 with no state). If it
503
+ * doesn't — the watcher attached at EOF after the server was down, or an
504
+ * append raced an in-flight backfill — extending would assign wrong
505
+ * message_index values over a hole. In that case this writes nothing and
506
+ * returns null so the caller drops the index and backfills.
507
+ *
508
+ * On success returns the message_index assigned to each input span (null for a
509
+ * non-message line) so the caller can stamp WS `seq`. Empty array when spans
510
+ * is empty. `endOffset` is stored verbatim as byte_offset so the watcher's
511
+ * offset and file_state.byte_offset are the same number by construction.
512
+ */
513
+ extendMessageIndex(filePath: string, spans: LineSpan[], stat: Stats, readFrom: number, endOffset: number): (number | null)[] | null;
514
+ clearIndexParseState(filePath: string): void;
515
+ /**
516
+ * On-demand full backfill of the offset index for a file with no/stale
517
+ * file_state (cold conversation, or after a truncation/replacement). Rebuilds
518
+ * from byte 0: drops any existing rows, walks the whole file in chunks with a
519
+ * running parse state, yields to the event loop every ~1000 lines so a large
520
+ * file never blocks, and writes index rows + file_state.
521
+ *
522
+ * Single-flighted per path: concurrent callers await the same walk. The
523
+ * triggering detail request is served by the scanner fallback while this runs.
524
+ */
525
+ backfillIndex(filePath: string): Promise<void>;
526
+ private runBackfill;
527
+ /**
528
+ * Windowed detail read straight from the offset index — the hot path.
529
+ * Returns the parsed messages for message_index in [fromIndex, toIndex) plus
530
+ * the total indexed count, or null when the index can't serve this file (no
531
+ * file_state, identity/size mismatch = truncation/replacement, or cold index)
532
+ * so the caller falls back to the scanner and enqueues a backfill.
533
+ *
534
+ * On a match it SQL-selects the window's byte ranges and preads exactly those
535
+ * ranges from the JSONL (never the whole file), parsing only the sliced lines.
536
+ * Returns messages in the same ConversationMessage shape parseJsonlLine
537
+ * produces during a scan, so the payload is identical to the scanner path.
538
+ */
539
+ readMessageWindow(filePath: string, fromIndex: number, toIndex: number): {
540
+ messages: ConversationMessage[];
541
+ total: number;
542
+ fromIndex: number;
543
+ } | null;
439
544
  private agentEntrypointsKey;
440
545
  private classifyAgentFile;
441
546
  isAgentFileCached(filePath: string): boolean;
@@ -874,6 +979,7 @@ declare class StreamerServer {
874
979
  private wsHub;
875
980
  private fileWatcher;
876
981
  private sessionFileMap;
982
+ private pendingLineSeqs;
877
983
  private pendingQuestions;
878
984
  private pendingQuestionKey;
879
985
  private pendingPermission;
@@ -1010,6 +1116,15 @@ interface ConversationWatcherEvents {
1010
1116
  * downstream cache write + WebSocket broadcast.
1011
1117
  */
1012
1118
  onNewLines?: (filePath: string, lines: string[]) => void;
1119
+ /**
1120
+ * Like onNewLines but also carries each line's absolute byte span in the
1121
+ * file (for the offset index). Fires ALONGSIDE onNewLines/onNewLine (it does
1122
+ * not replace them) so the cache tail write and the index extend can both
1123
+ * consume the same read. `readFrom` is the absolute byte offset the read
1124
+ * started at; `spans` are complete lines only (a torn trailing line is held
1125
+ * for the next read).
1126
+ */
1127
+ onNewLineSpans?: (filePath: string, spans: LineSpan[], readFrom: number, endOffset: number) => void;
1013
1128
  /** Fires when chokidar reports an add/change/unlink at the directory level. */
1014
1129
  onConversationChanged?: (filePath: string) => void | Promise<void>;
1015
1130
  /** Fires when a tailed file is deleted (per-file watcher unlink event). */
@@ -1033,6 +1148,7 @@ declare class ConversationWatcher {
1033
1148
  private directories;
1034
1149
  private onNewLine;
1035
1150
  private onNewLines;
1151
+ private onNewLineSpans;
1036
1152
  private onConversationChanged;
1037
1153
  private onFileDeleted;
1038
1154
  private onError;
package/dist/index.d.ts CHANGED
@@ -6,8 +6,9 @@ import { Logger as Logger$1 } from 'pino';
6
6
  import { HttpBindings } from '@hono/node-server';
7
7
  import { ServerResponse, IncomingMessage } from 'http';
8
8
  import { WebSocket } from 'ws';
9
- import { FileStatEntry, ConversationMeta } from '@threadbase-sh/scanner';
9
+ import { ConversationMessage, FileStatEntry, ConversationMeta } from '@threadbase-sh/scanner';
10
10
  import Database from 'better-sqlite3';
11
+ import { Stats } from 'fs';
11
12
  import { z } from 'zod';
12
13
  import { Pool } from 'pg';
13
14
 
@@ -190,6 +191,7 @@ type WSMessage = {
190
191
  type: "conversation_events";
191
192
  sessionId: string;
192
193
  lines: string[];
194
+ seqs?: (number | null)[];
193
195
  } | {
194
196
  type: "question";
195
197
  sessionId: string;
@@ -367,6 +369,15 @@ interface SessionRunner {
367
369
  dispose(): void;
368
370
  }
369
371
 
372
+ interface LineSpan {
373
+ /** Absolute byte offset of the line's first byte in the file. */
374
+ byteOffset: number;
375
+ /** Byte length of the line content, excluding the trailing "\n". */
376
+ byteLength: number;
377
+ /** The decoded line text (no trailing "\n"). */
378
+ text: string;
379
+ }
380
+
370
381
  interface ConversationCacheOptions {
371
382
  filterAgentConversations?: boolean;
372
383
  agentEntrypoints?: ReadonlySet<string>;
@@ -401,6 +412,25 @@ interface CachedTail {
401
412
  messages: CachedTailMessage[];
402
413
  tailSize: number;
403
414
  }
415
+ /** A row of `conversation_file_state` — per-file offset-index resume state. */
416
+ interface FileStateRow {
417
+ path: string;
418
+ identity: string;
419
+ size: number;
420
+ mtime_ms: number;
421
+ byte_offset: number;
422
+ last_message_index: number;
423
+ }
424
+ /** A row of `conversation_message_index` — one indexed message's byte span. */
425
+ interface MessageIndexRow {
426
+ conversation_id: string;
427
+ message_index: number;
428
+ byte_offset: number;
429
+ byte_length: number;
430
+ uuid: string | null;
431
+ role: string | null;
432
+ ts: number | null;
433
+ }
404
434
  interface ScannerMeta {
405
435
  id: string;
406
436
  sessionId?: string;
@@ -423,6 +453,8 @@ declare class ConversationCache {
423
453
  private tailSize;
424
454
  private fileIndex;
425
455
  private fileIndexLoaded;
456
+ private indexParseState;
457
+ private backfillInFlight;
426
458
  private tailSeq;
427
459
  private nameSeq;
428
460
  private stmts;
@@ -436,6 +468,79 @@ declare class ConversationCache {
436
468
  * share the same connection. Internal API; not part of the public surface.
437
469
  */
438
470
  getDatabase(): Database.Database;
471
+ getFileState(path: string): FileStateRow | null;
472
+ upsertFileState(row: FileStateRow): void;
473
+ /** Drop a file's index rows + file_state (truncation / identity change). */
474
+ deleteFileIndex(path: string, conversationId: string): void;
475
+ /** Append/replace index rows in one transaction. */
476
+ appendMessageIndexRows(rows: MessageIndexRow[]): void;
477
+ /** Rows for message_index in [fromIndex, toIndex), ordered ascending. */
478
+ getMessageIndexWindow(conversationId: string, fromIndex: number, toIndex: number): MessageIndexRow[];
479
+ getIndexedMessageCount(conversationId: string): number;
480
+ /**
481
+ * Conversation id for a JSONL path — the filename stem (matches the pseudo-id
482
+ * updateFromLine derives and the uuid the detail read path resolves). The
483
+ * offset index keys on this so the window select and the cursor agree.
484
+ */
485
+ static conversationIdForFile(filePath: string): string;
486
+ private isIndexableFile;
487
+ /**
488
+ * Incremental offset-index writer: extend the index for a burst of appended
489
+ * lines (one watcher read) using their byte spans. Each line is classified
490
+ * with the scanner's parseJsonlLine (a running per-file reducer state), so the
491
+ * message ordering can never drift from the scanner's. Message lines get an
492
+ * index row at the next message_index; non-message lines (summary/sidecar)
493
+ * get no row but still advance byte_offset. file_state is updated to the end
494
+ * of the last consumed span.
495
+ *
496
+ * Requires an up-to-date `stat` (identity/size/mtime) for the file so the read
497
+ * path can detect truncation/replacement.
498
+ *
499
+ * `readFrom` is the absolute byte offset the watcher read started at, and
500
+ * `endOffset` is where it ended (readFrom + consumed, i.e. the watcher's new
501
+ * entry.offset). CONTIGUITY GUARD: the read must begin exactly where the index
502
+ * left off (`readFrom === existing.byte_offset`, or 0 with no state). If it
503
+ * doesn't — the watcher attached at EOF after the server was down, or an
504
+ * append raced an in-flight backfill — extending would assign wrong
505
+ * message_index values over a hole. In that case this writes nothing and
506
+ * returns null so the caller drops the index and backfills.
507
+ *
508
+ * On success returns the message_index assigned to each input span (null for a
509
+ * non-message line) so the caller can stamp WS `seq`. Empty array when spans
510
+ * is empty. `endOffset` is stored verbatim as byte_offset so the watcher's
511
+ * offset and file_state.byte_offset are the same number by construction.
512
+ */
513
+ extendMessageIndex(filePath: string, spans: LineSpan[], stat: Stats, readFrom: number, endOffset: number): (number | null)[] | null;
514
+ clearIndexParseState(filePath: string): void;
515
+ /**
516
+ * On-demand full backfill of the offset index for a file with no/stale
517
+ * file_state (cold conversation, or after a truncation/replacement). Rebuilds
518
+ * from byte 0: drops any existing rows, walks the whole file in chunks with a
519
+ * running parse state, yields to the event loop every ~1000 lines so a large
520
+ * file never blocks, and writes index rows + file_state.
521
+ *
522
+ * Single-flighted per path: concurrent callers await the same walk. The
523
+ * triggering detail request is served by the scanner fallback while this runs.
524
+ */
525
+ backfillIndex(filePath: string): Promise<void>;
526
+ private runBackfill;
527
+ /**
528
+ * Windowed detail read straight from the offset index — the hot path.
529
+ * Returns the parsed messages for message_index in [fromIndex, toIndex) plus
530
+ * the total indexed count, or null when the index can't serve this file (no
531
+ * file_state, identity/size mismatch = truncation/replacement, or cold index)
532
+ * so the caller falls back to the scanner and enqueues a backfill.
533
+ *
534
+ * On a match it SQL-selects the window's byte ranges and preads exactly those
535
+ * ranges from the JSONL (never the whole file), parsing only the sliced lines.
536
+ * Returns messages in the same ConversationMessage shape parseJsonlLine
537
+ * produces during a scan, so the payload is identical to the scanner path.
538
+ */
539
+ readMessageWindow(filePath: string, fromIndex: number, toIndex: number): {
540
+ messages: ConversationMessage[];
541
+ total: number;
542
+ fromIndex: number;
543
+ } | null;
439
544
  private agentEntrypointsKey;
440
545
  private classifyAgentFile;
441
546
  isAgentFileCached(filePath: string): boolean;
@@ -874,6 +979,7 @@ declare class StreamerServer {
874
979
  private wsHub;
875
980
  private fileWatcher;
876
981
  private sessionFileMap;
982
+ private pendingLineSeqs;
877
983
  private pendingQuestions;
878
984
  private pendingQuestionKey;
879
985
  private pendingPermission;
@@ -1010,6 +1116,15 @@ interface ConversationWatcherEvents {
1010
1116
  * downstream cache write + WebSocket broadcast.
1011
1117
  */
1012
1118
  onNewLines?: (filePath: string, lines: string[]) => void;
1119
+ /**
1120
+ * Like onNewLines but also carries each line's absolute byte span in the
1121
+ * file (for the offset index). Fires ALONGSIDE onNewLines/onNewLine (it does
1122
+ * not replace them) so the cache tail write and the index extend can both
1123
+ * consume the same read. `readFrom` is the absolute byte offset the read
1124
+ * started at; `spans` are complete lines only (a torn trailing line is held
1125
+ * for the next read).
1126
+ */
1127
+ onNewLineSpans?: (filePath: string, spans: LineSpan[], readFrom: number, endOffset: number) => void;
1013
1128
  /** Fires when chokidar reports an add/change/unlink at the directory level. */
1014
1129
  onConversationChanged?: (filePath: string) => void | Promise<void>;
1015
1130
  /** Fires when a tailed file is deleted (per-file watcher unlink event). */
@@ -1033,6 +1148,7 @@ declare class ConversationWatcher {
1033
1148
  private directories;
1034
1149
  private onNewLine;
1035
1150
  private onNewLines;
1151
+ private onNewLineSpans;
1036
1152
  private onConversationChanged;
1037
1153
  private onFileDeleted;
1038
1154
  private onError;