@threadbase-sh/streamer 1.27.3 → 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.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,78 @@ 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
+ /**
487
+ * Incremental offset-index writer: extend the index for a burst of appended
488
+ * lines (one watcher read) using their byte spans. Each line is classified
489
+ * with the scanner's parseJsonlLine (a running per-file reducer state), so the
490
+ * message ordering can never drift from the scanner's. Message lines get an
491
+ * index row at the next message_index; non-message lines (summary/sidecar)
492
+ * get no row but still advance byte_offset. file_state is updated to the end
493
+ * of the last consumed span.
494
+ *
495
+ * Requires an up-to-date `stat` (identity/size/mtime) for the file so the read
496
+ * path can detect truncation/replacement.
497
+ *
498
+ * `readFrom` is the absolute byte offset the watcher read started at, and
499
+ * `endOffset` is where it ended (readFrom + consumed, i.e. the watcher's new
500
+ * entry.offset). CONTIGUITY GUARD: the read must begin exactly where the index
501
+ * left off (`readFrom === existing.byte_offset`, or 0 with no state). If it
502
+ * doesn't — the watcher attached at EOF after the server was down, or an
503
+ * append raced an in-flight backfill — extending would assign wrong
504
+ * message_index values over a hole. In that case this writes nothing and
505
+ * returns null so the caller drops the index and backfills.
506
+ *
507
+ * On success returns the message_index assigned to each input span (null for a
508
+ * non-message line) so the caller can stamp WS `seq`. Empty array when spans
509
+ * is empty. `endOffset` is stored verbatim as byte_offset so the watcher's
510
+ * offset and file_state.byte_offset are the same number by construction.
511
+ */
512
+ extendMessageIndex(filePath: string, spans: LineSpan[], stat: Stats, readFrom: number, endOffset: number): (number | null)[] | null;
513
+ clearIndexParseState(filePath: string): void;
514
+ /**
515
+ * On-demand full backfill of the offset index for a file with no/stale
516
+ * file_state (cold conversation, or after a truncation/replacement). Rebuilds
517
+ * from byte 0: drops any existing rows, walks the whole file in chunks with a
518
+ * running parse state, yields to the event loop every ~1000 lines so a large
519
+ * file never blocks, and writes index rows + file_state.
520
+ *
521
+ * Single-flighted per path: concurrent callers await the same walk. The
522
+ * triggering detail request is served by the scanner fallback while this runs.
523
+ */
524
+ backfillIndex(filePath: string): Promise<void>;
525
+ private runBackfill;
526
+ /**
527
+ * Windowed detail read straight from the offset index — the hot path.
528
+ * Returns the parsed messages for message_index in [fromIndex, toIndex) plus
529
+ * the total indexed count, or null when the index can't serve this file (no
530
+ * file_state, identity/size mismatch = truncation/replacement, or cold index)
531
+ * so the caller falls back to the scanner and enqueues a backfill.
532
+ *
533
+ * On a match it SQL-selects the window's byte ranges and preads exactly those
534
+ * ranges from the JSONL (never the whole file), parsing only the sliced lines.
535
+ * Returns messages in the same ConversationMessage shape parseJsonlLine
536
+ * produces during a scan, so the payload is identical to the scanner path.
537
+ */
538
+ readMessageWindow(filePath: string, fromIndex: number, toIndex: number): {
539
+ messages: ConversationMessage[];
540
+ total: number;
541
+ fromIndex: number;
542
+ } | null;
439
543
  private agentEntrypointsKey;
440
544
  private classifyAgentFile;
441
545
  isAgentFileCached(filePath: string): boolean;
@@ -874,6 +978,7 @@ declare class StreamerServer {
874
978
  private wsHub;
875
979
  private fileWatcher;
876
980
  private sessionFileMap;
981
+ private pendingLineSeqs;
877
982
  private pendingQuestions;
878
983
  private pendingQuestionKey;
879
984
  private pendingPermission;
@@ -1010,6 +1115,15 @@ interface ConversationWatcherEvents {
1010
1115
  * downstream cache write + WebSocket broadcast.
1011
1116
  */
1012
1117
  onNewLines?: (filePath: string, lines: string[]) => void;
1118
+ /**
1119
+ * Like onNewLines but also carries each line's absolute byte span in the
1120
+ * file (for the offset index). Fires ALONGSIDE onNewLines/onNewLine (it does
1121
+ * not replace them) so the cache tail write and the index extend can both
1122
+ * consume the same read. `readFrom` is the absolute byte offset the read
1123
+ * started at; `spans` are complete lines only (a torn trailing line is held
1124
+ * for the next read).
1125
+ */
1126
+ onNewLineSpans?: (filePath: string, spans: LineSpan[], readFrom: number, endOffset: number) => void;
1013
1127
  /** Fires when chokidar reports an add/change/unlink at the directory level. */
1014
1128
  onConversationChanged?: (filePath: string) => void | Promise<void>;
1015
1129
  /** Fires when a tailed file is deleted (per-file watcher unlink event). */
@@ -1033,6 +1147,7 @@ declare class ConversationWatcher {
1033
1147
  private directories;
1034
1148
  private onNewLine;
1035
1149
  private onNewLines;
1150
+ private onNewLineSpans;
1036
1151
  private onConversationChanged;
1037
1152
  private onFileDeleted;
1038
1153
  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,78 @@ 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
+ /**
487
+ * Incremental offset-index writer: extend the index for a burst of appended
488
+ * lines (one watcher read) using their byte spans. Each line is classified
489
+ * with the scanner's parseJsonlLine (a running per-file reducer state), so the
490
+ * message ordering can never drift from the scanner's. Message lines get an
491
+ * index row at the next message_index; non-message lines (summary/sidecar)
492
+ * get no row but still advance byte_offset. file_state is updated to the end
493
+ * of the last consumed span.
494
+ *
495
+ * Requires an up-to-date `stat` (identity/size/mtime) for the file so the read
496
+ * path can detect truncation/replacement.
497
+ *
498
+ * `readFrom` is the absolute byte offset the watcher read started at, and
499
+ * `endOffset` is where it ended (readFrom + consumed, i.e. the watcher's new
500
+ * entry.offset). CONTIGUITY GUARD: the read must begin exactly where the index
501
+ * left off (`readFrom === existing.byte_offset`, or 0 with no state). If it
502
+ * doesn't — the watcher attached at EOF after the server was down, or an
503
+ * append raced an in-flight backfill — extending would assign wrong
504
+ * message_index values over a hole. In that case this writes nothing and
505
+ * returns null so the caller drops the index and backfills.
506
+ *
507
+ * On success returns the message_index assigned to each input span (null for a
508
+ * non-message line) so the caller can stamp WS `seq`. Empty array when spans
509
+ * is empty. `endOffset` is stored verbatim as byte_offset so the watcher's
510
+ * offset and file_state.byte_offset are the same number by construction.
511
+ */
512
+ extendMessageIndex(filePath: string, spans: LineSpan[], stat: Stats, readFrom: number, endOffset: number): (number | null)[] | null;
513
+ clearIndexParseState(filePath: string): void;
514
+ /**
515
+ * On-demand full backfill of the offset index for a file with no/stale
516
+ * file_state (cold conversation, or after a truncation/replacement). Rebuilds
517
+ * from byte 0: drops any existing rows, walks the whole file in chunks with a
518
+ * running parse state, yields to the event loop every ~1000 lines so a large
519
+ * file never blocks, and writes index rows + file_state.
520
+ *
521
+ * Single-flighted per path: concurrent callers await the same walk. The
522
+ * triggering detail request is served by the scanner fallback while this runs.
523
+ */
524
+ backfillIndex(filePath: string): Promise<void>;
525
+ private runBackfill;
526
+ /**
527
+ * Windowed detail read straight from the offset index — the hot path.
528
+ * Returns the parsed messages for message_index in [fromIndex, toIndex) plus
529
+ * the total indexed count, or null when the index can't serve this file (no
530
+ * file_state, identity/size mismatch = truncation/replacement, or cold index)
531
+ * so the caller falls back to the scanner and enqueues a backfill.
532
+ *
533
+ * On a match it SQL-selects the window's byte ranges and preads exactly those
534
+ * ranges from the JSONL (never the whole file), parsing only the sliced lines.
535
+ * Returns messages in the same ConversationMessage shape parseJsonlLine
536
+ * produces during a scan, so the payload is identical to the scanner path.
537
+ */
538
+ readMessageWindow(filePath: string, fromIndex: number, toIndex: number): {
539
+ messages: ConversationMessage[];
540
+ total: number;
541
+ fromIndex: number;
542
+ } | null;
439
543
  private agentEntrypointsKey;
440
544
  private classifyAgentFile;
441
545
  isAgentFileCached(filePath: string): boolean;
@@ -874,6 +978,7 @@ declare class StreamerServer {
874
978
  private wsHub;
875
979
  private fileWatcher;
876
980
  private sessionFileMap;
981
+ private pendingLineSeqs;
877
982
  private pendingQuestions;
878
983
  private pendingQuestionKey;
879
984
  private pendingPermission;
@@ -1010,6 +1115,15 @@ interface ConversationWatcherEvents {
1010
1115
  * downstream cache write + WebSocket broadcast.
1011
1116
  */
1012
1117
  onNewLines?: (filePath: string, lines: string[]) => void;
1118
+ /**
1119
+ * Like onNewLines but also carries each line's absolute byte span in the
1120
+ * file (for the offset index). Fires ALONGSIDE onNewLines/onNewLine (it does
1121
+ * not replace them) so the cache tail write and the index extend can both
1122
+ * consume the same read. `readFrom` is the absolute byte offset the read
1123
+ * started at; `spans` are complete lines only (a torn trailing line is held
1124
+ * for the next read).
1125
+ */
1126
+ onNewLineSpans?: (filePath: string, spans: LineSpan[], readFrom: number, endOffset: number) => void;
1013
1127
  /** Fires when chokidar reports an add/change/unlink at the directory level. */
1014
1128
  onConversationChanged?: (filePath: string) => void | Promise<void>;
1015
1129
  /** Fires when a tailed file is deleted (per-file watcher unlink event). */
@@ -1033,6 +1147,7 @@ declare class ConversationWatcher {
1033
1147
  private directories;
1034
1148
  private onNewLine;
1035
1149
  private onNewLines;
1150
+ private onNewLineSpans;
1036
1151
  private onConversationChanged;
1037
1152
  private onFileDeleted;
1038
1153
  private onError;