@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/cli.cjs +480 -54
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +449 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +116 -1
- package/dist/index.d.ts +116 -1
- package/dist/index.js +430 -11
- package/dist/index.js.map +1 -1
- package/dist/migrations/009_create_offset_index.sql +28 -0
- package/package.json +1 -1
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;
|