@jhizzard/termdeck 0.3.0 → 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.
- package/package.json +1 -1
- package/packages/cli/src/index.js +8 -0
- package/packages/client/public/app.js +471 -0
- package/packages/client/public/style.css +337 -0
- package/packages/server/src/index.js +102 -3
- package/packages/server/src/mnestra-bridge/index.js +1 -1
- package/packages/server/src/preflight.js +373 -0
- package/packages/server/src/rag.js +40 -0
- package/packages/server/src/session.js +8 -1
- package/packages/server/src/transcripts.js +290 -0
|
@@ -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 };
|