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