@jhizzard/termdeck 0.3.0 → 0.3.2

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.
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+
3
+ // TranscriptWriter — batched, non-blocking PTY output archiver.
4
+ // Buffers chunks in memory and flushes to Supabase/Postgres on an interval.
5
+ // Circuit breaker prevents cascade failure if the database is unreachable.
6
+
7
+ let pg;
8
+ try { pg = require('pg'); } catch (err) { pg = null; }
9
+
10
+ // Strip ANSI escape codes (CSI sequences, OSC sequences, simple escapes)
11
+ function stripAnsi(str) {
12
+ return str
13
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '') // OSC sequences
14
+ .replace(/\x1b\[[\?]?[0-9;]*[A-Za-z]/g, '') // CSI sequences
15
+ .replace(/\x1b[()][AB012]/g, '') // charset switches
16
+ .replace(/\x1b[\x20-\x2F]*[\x40-\x7E]/g, ''); // remaining two-byte escapes
17
+ }
18
+
19
+ class TranscriptWriter {
20
+ /**
21
+ * @param {string} databaseUrl - Postgres connection string
22
+ * @param {object} [options]
23
+ * @param {number} [options.batchSize=50] - max chunks per flush
24
+ * @param {number} [options.flushIntervalMs=2000] - flush timer interval
25
+ * @param {boolean} [options.enabled=true] - master on/off
26
+ */
27
+ constructor(databaseUrl, options = {}) {
28
+ this._databaseUrl = databaseUrl;
29
+ this._batchSize = options.batchSize || 50;
30
+ this._flushIntervalMs = options.flushIntervalMs || 2000;
31
+ this._maxBufferSize = options.maxBufferSize || 10000;
32
+ this._enabled = options.enabled !== false;
33
+
34
+ // Per-session monotonic chunk counters
35
+ this._counters = new Map(); // sessionId -> next chunk_index
36
+
37
+ // Write buffer: array of { sessionId, content, rawBytes, chunkIndex }
38
+ this._buffer = [];
39
+
40
+ // Circuit breaker state
41
+ this._consecutiveErrors = 0;
42
+ this._circuitOpen = false;
43
+ this._circuitOpenedAt = 0;
44
+ this._circuitCooldownMs = 60000; // 60s
45
+
46
+ // Lazy pool
47
+ this._pool = null;
48
+ this._poolFailed = false;
49
+
50
+ // Start flush timer
51
+ this._timer = null;
52
+ if (this._enabled) {
53
+ this._timer = setInterval(() => this.flush().catch(() => {}), this._flushIntervalMs);
54
+ }
55
+ }
56
+
57
+ // Lazy-init pg.Pool (same pattern as getRumenPool in index.js)
58
+ _getPool() {
59
+ if (this._pool || this._poolFailed) return this._pool;
60
+ if (!pg || !this._databaseUrl) return null;
61
+ try {
62
+ this._pool = new pg.Pool({
63
+ connectionString: this._databaseUrl,
64
+ max: 3,
65
+ idleTimeoutMillis: 30000,
66
+ connectionTimeoutMillis: 5000
67
+ });
68
+ this._pool.on('error', (err) => {
69
+ console.error('[transcript] pool error:', err.message);
70
+ });
71
+ return this._pool;
72
+ } catch (err) {
73
+ console.error('[transcript] pool creation failed:', err.message);
74
+ this._poolFailed = true;
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Queue a chunk for writing. Non-blocking. Returns immediately.
81
+ * @param {string} sessionId
82
+ * @param {string} content - raw PTY output (may contain ANSI)
83
+ * @param {number} rawByteCount - byte length of original data
84
+ */
85
+ append(sessionId, content, rawByteCount) {
86
+ if (!this._enabled) return;
87
+
88
+ const stripped = stripAnsi(content);
89
+ if (!stripped.trim()) return; // skip empty-after-strip chunks
90
+
91
+ // Monotonic chunk index per session
92
+ const idx = this._counters.get(sessionId) || 0;
93
+ this._counters.set(sessionId, idx + 1);
94
+
95
+ // Cap buffer to prevent unbounded growth during sustained DB failures
96
+ if (this._buffer.length >= this._maxBufferSize) {
97
+ this._buffer.splice(0, this._buffer.length - this._maxBufferSize + 1);
98
+ }
99
+
100
+ this._buffer.push({
101
+ sessionId,
102
+ content: stripped,
103
+ rawBytes: rawByteCount || Buffer.byteLength(content, 'utf8'),
104
+ chunkIndex: idx
105
+ });
106
+
107
+ // Auto-flush if buffer is full
108
+ if (this._buffer.length >= this._batchSize) {
109
+ this.flush().catch(() => {});
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Flush pending chunks to Postgres. Called on interval and on shutdown.
115
+ */
116
+ async flush() {
117
+ if (!this._enabled || this._buffer.length === 0) return;
118
+
119
+ // Circuit breaker check
120
+ if (this._circuitOpen) {
121
+ const elapsed = Date.now() - this._circuitOpenedAt;
122
+ if (elapsed < this._circuitCooldownMs) return;
123
+ // Cooldown expired — half-open, try one flush
124
+ this._circuitOpen = false;
125
+ console.log('[transcript] circuit breaker half-open, retrying');
126
+ }
127
+
128
+ const pool = this._getPool();
129
+ if (!pool) return;
130
+
131
+ // Drain buffer (take up to batchSize)
132
+ const batch = this._buffer.splice(0, this._batchSize);
133
+ if (batch.length === 0) return;
134
+
135
+ try {
136
+ // Build a multi-row INSERT
137
+ const values = [];
138
+ const params = [];
139
+ let paramIdx = 1;
140
+
141
+ for (const chunk of batch) {
142
+ values.push(`($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3})`);
143
+ params.push(chunk.sessionId, chunk.chunkIndex, chunk.content, chunk.rawBytes);
144
+ paramIdx += 4;
145
+ }
146
+
147
+ const sql = `INSERT INTO termdeck_transcripts (session_id, chunk_index, content, raw_bytes)
148
+ VALUES ${values.join(', ')}`;
149
+
150
+ await pool.query(sql, params);
151
+
152
+ // Success — reset circuit breaker
153
+ this._consecutiveErrors = 0;
154
+ } catch (err) {
155
+ console.error('[transcript] flush error:', err.message);
156
+
157
+ // Put chunks back at front of buffer for retry
158
+ this._buffer.unshift(...batch);
159
+
160
+ this._consecutiveErrors++;
161
+ if (this._consecutiveErrors >= 3) {
162
+ this._circuitOpen = true;
163
+ this._circuitOpenedAt = Date.now();
164
+ console.error('[transcript] circuit breaker open — disabling writes for 60s');
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Retrieve transcript for crash recovery.
171
+ * @param {string} sessionId
172
+ * @param {object} [options]
173
+ * @param {number} [options.limit] - max chunks to return
174
+ * @param {string} [options.since] - ISO timestamp, only chunks after this time
175
+ * @returns {Promise<Array<{chunk_index, content, raw_bytes, created_at}>>}
176
+ */
177
+ async getSessionTranscript(sessionId, { limit, since } = {}) {
178
+ const pool = this._getPool();
179
+ if (!pool) return [];
180
+
181
+ let sql = 'SELECT chunk_index, content, raw_bytes, created_at FROM termdeck_transcripts WHERE session_id = $1';
182
+ const params = [sessionId];
183
+ let paramIdx = 2;
184
+
185
+ if (since) {
186
+ sql += ` AND created_at >= $${paramIdx}`;
187
+ params.push(since);
188
+ paramIdx++;
189
+ }
190
+
191
+ sql += ' ORDER BY chunk_index ASC';
192
+
193
+ if (limit) {
194
+ sql += ` LIMIT $${paramIdx}`;
195
+ params.push(limit);
196
+ }
197
+
198
+ try {
199
+ const result = await pool.query(sql, params);
200
+ return result.rows;
201
+ } catch (err) {
202
+ console.error('[transcript] getSessionTranscript error:', err.message);
203
+ return [];
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Search across all transcripts using full-text search.
209
+ * @param {string} query - search terms
210
+ * @param {object} [options]
211
+ * @param {string} [options.sessionId] - restrict to one session
212
+ * @param {string} [options.since] - ISO timestamp lower bound
213
+ * @param {number} [options.limit=50] - max results
214
+ * @returns {Promise<Array<{session_id, chunk_index, content, created_at, rank}>>}
215
+ */
216
+ async search(query, { sessionId, since, limit = 50 } = {}) {
217
+ const pool = this._getPool();
218
+ if (!pool) return [];
219
+
220
+ let sql = `SELECT session_id, chunk_index, content, created_at,
221
+ ts_rank(fts, websearch_to_tsquery('english', $1)) AS rank
222
+ FROM termdeck_transcripts
223
+ WHERE fts @@ websearch_to_tsquery('english', $1)`;
224
+ const params = [query];
225
+ let paramIdx = 2;
226
+
227
+ if (sessionId) {
228
+ sql += ` AND session_id = $${paramIdx}`;
229
+ params.push(sessionId);
230
+ paramIdx++;
231
+ }
232
+
233
+ if (since) {
234
+ sql += ` AND created_at >= $${paramIdx}`;
235
+ params.push(since);
236
+ paramIdx++;
237
+ }
238
+
239
+ sql += ` ORDER BY rank DESC, created_at DESC LIMIT $${paramIdx}`;
240
+ params.push(limit);
241
+
242
+ try {
243
+ const result = await pool.query(sql, params);
244
+ return result.rows;
245
+ } catch (err) {
246
+ console.error('[transcript] search error:', err.message);
247
+ return [];
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Get recent transcript chunks across all sessions (crash recovery).
253
+ * @param {number} [minutes=60] - how far back to look
254
+ * @param {number} [limit=500] - max rows
255
+ * @returns {Promise<Array>}
256
+ */
257
+ async getRecent(minutes = 60, limit = 500) {
258
+ const pool = this._getPool();
259
+ if (!pool) return [];
260
+
261
+ const sql = `SELECT session_id, chunk_index, content, raw_bytes, created_at
262
+ FROM termdeck_transcripts
263
+ WHERE created_at >= NOW() - $1::interval
264
+ ORDER BY created_at DESC
265
+ LIMIT $2`;
266
+
267
+ try {
268
+ const result = await pool.query(sql, [`${minutes} minutes`, limit]);
269
+ return result.rows;
270
+ } catch (err) {
271
+ console.error('[transcript] getRecent error:', err.message);
272
+ return [];
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Graceful shutdown — flush remaining buffer and close pool.
278
+ */
279
+ async close() {
280
+ if (this._timer) {
281
+ clearInterval(this._timer);
282
+ this._timer = null;
283
+ }
284
+
285
+ // Force flush remaining buffer (bypass circuit breaker for shutdown)
286
+ this._circuitOpen = false;
287
+ await this.flush();
288
+
289
+ if (this._pool) {
290
+ try { await this._pool.end(); } catch (err) { console.warn('[transcript] pool close error:', err.message); }
291
+ this._pool = null;
292
+ }
293
+ }
294
+ }
295
+
296
+ module.exports = { TranscriptWriter, stripAnsi };