glad-web 1.0.7

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,382 @@
1
+ const { EventEmitter } = require('events');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const { v4: uuidv4 } = require('uuid');
5
+ const PTYManager = require('./pty-manager');
6
+ const TextHistory = require('./text-history');
7
+ const RenderedHistory = require('./rendered-history');
8
+ const CircularBuffer = require('./buffer');
9
+ const { getToolByKey } = require('../ai-tools/registry');
10
+
11
+ function previewText(text, maxChars = 320) {
12
+ if (!text) return '';
13
+ const normalized = String(text)
14
+ .replace(/\r/g, '\\r')
15
+ .replace(/\n/g, '\\n')
16
+ .replace(/\t/g, '\\t')
17
+ .replace(/\x1b/g, '\\x1b');
18
+ return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
19
+ }
20
+
21
+ class SessionManager extends EventEmitter {
22
+ constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient } = {}) {
23
+ super();
24
+ this.baseDir = baseDir || process.cwd();
25
+ this.renderHistoryTools = renderHistoryTools || new Set();
26
+ this.debugHistoryEnabled = debugHistoryEnabled;
27
+ this.logger = logger || console;
28
+ this.hasConnectedSessionClient = hasConnectedSessionClient || (() => false);
29
+ this.sessions = new Map();
30
+ }
31
+
32
+ list() {
33
+ return Array.from(this.sessions.entries()).map(([id, session]) => ({
34
+ id,
35
+ name: session.name,
36
+ tool: session.tool.displayName,
37
+ startTime: session.startTime,
38
+ toolKey: session.tool.key,
39
+ workingDirectory: session.ptyManager.workingDir,
40
+ hasUnreadCompletion: Boolean(session.hasUnreadCompletion)
41
+ }));
42
+ }
43
+
44
+ get(id) {
45
+ return this.sessions.get(id) || null;
46
+ }
47
+
48
+ has(id) {
49
+ return this.sessions.has(id);
50
+ }
51
+
52
+ create({ toolKey, workingDirectory, name }) {
53
+ this.logger.info(`Creating session: toolKey=${toolKey || ''}, workingDirectory=${workingDirectory || '(default)'}`);
54
+ const tool = getToolByKey(toolKey);
55
+ if (!tool) {
56
+ const err = new Error('Invalid tool');
57
+ err.statusCode = 400;
58
+ this.logger.error(`Create session failed: invalid toolKey=${toolKey || ''}`);
59
+ throw err;
60
+ }
61
+
62
+ const id = uuidv4();
63
+ const buffer = new CircularBuffer(500000);
64
+ const textHistory = new TextHistory({ maxBytes: 20 * 1024 * 1024, debugLabel: id });
65
+ const historyMode = this.getHistoryModeForTool(tool.key);
66
+ const renderedHistory = historyMode === 'rendered'
67
+ ? new RenderedHistory({ maxBytes: 20 * 1024 * 1024, debugLabel: id, cols: 80, rows: 24 })
68
+ : null;
69
+
70
+ const sessionDir = workingDirectory && String(workingDirectory).trim()
71
+ ? path.resolve(this.baseDir, String(workingDirectory).trim())
72
+ : this.baseDir;
73
+
74
+ if (!fs.existsSync(sessionDir)) {
75
+ const err = new Error(`Directory does not exist: ${sessionDir}`);
76
+ err.statusCode = 400;
77
+ this.logger.error(`Create session failed: missing directory ${sessionDir}`);
78
+ throw err;
79
+ }
80
+
81
+ this.logger.info(`Resolved session directory: ${sessionDir}`);
82
+
83
+ const ptyManager = new PTYManager(tool, sessionDir, buffer, { silent: true });
84
+ const session = {
85
+ id,
86
+ name: name || tool.displayName,
87
+ ptyManager,
88
+ buffer,
89
+ textHistory,
90
+ renderedHistory,
91
+ historyMode,
92
+ tool,
93
+ startTime: Date.now(),
94
+ isThinking: false,
95
+ completionTimer: null,
96
+ awaitingCompletion: false,
97
+ inputSeq: 0,
98
+ completionReadInputSeq: 0,
99
+ resizeOwner: null,
100
+ hasUnreadCompletion: false,
101
+ write: data => this.write(id, data),
102
+ isRunning: () => this.has(id) && ptyManager.isRunning(),
103
+ kill: () => this.kill(id)
104
+ };
105
+
106
+ this.sessions.set(id, session);
107
+ this.logSessionDiagnostics('session-created', session, {}, { compact: true });
108
+
109
+ ptyManager.onData((data) => this.handleOutput(session, data));
110
+ ptyManager.onExit(() => this.handleExit(session));
111
+
112
+ const started = ptyManager.start([]);
113
+ if (!started) {
114
+ const err = new Error(`Failed to start ${tool.displayName}`);
115
+ err.statusCode = 500;
116
+ this.sessions.delete(id);
117
+ throw err;
118
+ }
119
+
120
+ return session;
121
+ }
122
+
123
+ write(id, data) {
124
+ const session = this.get(id);
125
+ if (!session) return false;
126
+ this.markSessionInput(session, data);
127
+ return session.ptyManager.write(data);
128
+ }
129
+
130
+ resize(id, cols, rows) {
131
+ const session = this.get(id);
132
+ if (!session) return false;
133
+ if (session.renderedHistory) {
134
+ session.renderedHistory.resize(cols, rows);
135
+ }
136
+ session.ptyManager.resize(cols, rows);
137
+ return true;
138
+ }
139
+
140
+ rename(id, name) {
141
+ const session = this.get(id);
142
+ if (!session || !name) return null;
143
+ session.name = name;
144
+ this.logSessionDiagnostics('session-renamed', session, {}, { compact: true });
145
+ return session;
146
+ }
147
+
148
+ markCompletionRead(id) {
149
+ const session = this.get(id);
150
+ if (!session) return null;
151
+ session.hasUnreadCompletion = false;
152
+ session.awaitingCompletion = false;
153
+ session.isThinking = false;
154
+ session.completionReadInputSeq = session.inputSeq || 0;
155
+ clearTimeout(session.completionTimer);
156
+ this.logSessionDiagnostics('completion-read', session, {}, { compact: true });
157
+ return session;
158
+ }
159
+
160
+ kill(id) {
161
+ const session = this.get(id);
162
+ if (!session) return false;
163
+ clearTimeout(session.completionTimer);
164
+ this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
165
+ this.disposeSessionHistory(session);
166
+ session.ptyManager.kill();
167
+ this.sessions.delete(id);
168
+ this.emit('exit', { sessionId: id, session });
169
+ return true;
170
+ }
171
+
172
+ killAll() {
173
+ for (const session of this.sessions.values()) {
174
+ clearTimeout(session.completionTimer);
175
+ this.disposeSessionHistory(session);
176
+ session.ptyManager.kill();
177
+ }
178
+ this.sessions.clear();
179
+ }
180
+
181
+ getHistory(id) {
182
+ const session = this.get(id);
183
+ if (!session) return null;
184
+ const historySource = session.renderedHistory || session.textHistory;
185
+ return {
186
+ success: true,
187
+ sessionId: session.id,
188
+ sessionName: session.name,
189
+ tool: session.tool.displayName,
190
+ historyMode: session.historyMode,
191
+ ...historySource.toJSON()
192
+ };
193
+ }
194
+
195
+ getDiagnostics(id) {
196
+ const session = this.get(id);
197
+ return session ? this.getSessionDiagnostics(session) : null;
198
+ }
199
+
200
+ logHistoryRequest(id, req) {
201
+ const session = this.get(id);
202
+ if (!session) return;
203
+ this.logSessionDiagnostics('history-request', session, {
204
+ userAgent: req.headers['user-agent'] || '',
205
+ acceptEncoding: req.headers['accept-encoding'] || ''
206
+ }, { compact: true });
207
+ }
208
+
209
+ logClientDebug(sessionId, event, payload) {
210
+ const session = sessionId ? this.get(sessionId) : null;
211
+ this.logger.debugInfo(`[client-debug] ${JSON.stringify({
212
+ sessionId: sessionId || null,
213
+ event: event || 'unknown',
214
+ payload: payload || null,
215
+ serverSide: session ? this.getSessionDiagnostics(session) : null
216
+ })}`);
217
+ }
218
+
219
+ logWsConnected(id, req) {
220
+ const session = this.get(id);
221
+ if (!session) return;
222
+ this.logSessionDiagnostics('ws-connected', session, {
223
+ remoteAddress: req.socket.remoteAddress || null,
224
+ userAgent: req.headers['user-agent'] || ''
225
+ }, { compact: true });
226
+ }
227
+
228
+ logWsCatchup(id, history) {
229
+ const session = this.get(id);
230
+ if (!session) return;
231
+ this.logSessionDiagnostics('ws-catchup', session, {
232
+ catchupItems: history.length,
233
+ catchupPreview: previewText(history.map(message => message.data).join(''))
234
+ }, { compact: true });
235
+ }
236
+
237
+ logWsResize(id, cols, rows) {
238
+ const session = this.get(id);
239
+ if (!session) return;
240
+ this.logSessionDiagnostics('ws-resize', session, { cols, rows }, { compact: true });
241
+ }
242
+
243
+ logWsClosed(id) {
244
+ const session = this.get(id);
245
+ if (!session) return;
246
+ this.logSessionDiagnostics('ws-closed', session, {}, { compact: true });
247
+ }
248
+
249
+ getHistoryModeForTool(toolKey) {
250
+ return this.renderHistoryTools.has(String(toolKey || '').toLowerCase()) ? 'rendered' : 'transcript';
251
+ }
252
+
253
+ handleOutput(session, data) {
254
+ session.textHistory.write(data);
255
+ if (session.renderedHistory) {
256
+ session.renderedHistory.write(data);
257
+ }
258
+ this.logSessionDiagnostics('pty-output', session, {
259
+ chunkBytes: Buffer.byteLength(String(data), 'utf8'),
260
+ chunkPreview: previewText(data),
261
+ containsClear: /\x1b\[[0-9;?]*J/.test(data),
262
+ containsCursorMove: /\x1b\[[0-9;?]*(?:[ABCDGHf])/.test(data)
263
+ }, { compact: true });
264
+
265
+ if (session.awaitingCompletion && !session.isThinking && data.trim().length > 0) session.isThinking = true;
266
+ if (session.awaitingCompletion && session.isThinking) {
267
+ clearTimeout(session.completionTimer);
268
+ const watchedInputSeq = session.inputSeq || 0;
269
+ session.completionTimer = setTimeout(() => {
270
+ const hasUnreadInput = watchedInputSeq > (session.completionReadInputSeq || 0);
271
+ const isCurrentInput = watchedInputSeq === (session.inputSeq || 0);
272
+ if (session.awaitingCompletion && hasUnreadInput && isCurrentInput && !this.hasConnectedSessionClient(session.id)) {
273
+ session.hasUnreadCompletion = true;
274
+ }
275
+ session.awaitingCompletion = false;
276
+ session.isThinking = false;
277
+ }, 10000);
278
+ }
279
+
280
+ this.emit('output', { sessionId: session.id, data, session });
281
+ }
282
+
283
+ handleExit(session) {
284
+ if (!this.sessions.has(session.id)) return;
285
+ this.logger.info(`Session ${session.id} (${session.name}) exited.`);
286
+ clearTimeout(session.completionTimer);
287
+ this.disposeSessionHistory(session);
288
+ this.sessions.delete(session.id);
289
+ this.emit('exit', { sessionId: session.id, session });
290
+ }
291
+
292
+ markSessionInput(session, data) {
293
+ if (typeof data !== 'string' || data.length === 0) return;
294
+ session.inputSeq = (session.inputSeq || 0) + 1;
295
+ session.awaitingCompletion = true;
296
+ session.isThinking = false;
297
+ session.hasUnreadCompletion = false;
298
+ clearTimeout(session.completionTimer);
299
+ this.logSessionDiagnostics('session-input', session, {
300
+ inputSeq: session.inputSeq,
301
+ inputPreview: previewText(data)
302
+ }, { compact: true });
303
+ }
304
+
305
+ disposeSessionHistory(session) {
306
+ if (session.renderedHistory) {
307
+ session.renderedHistory.dispose();
308
+ session.renderedHistory = null;
309
+ }
310
+ }
311
+
312
+ getSessionDiagnostics(session, extra = {}) {
313
+ return {
314
+ sessionId: session.id,
315
+ sessionName: session.name,
316
+ toolKey: session.tool.key,
317
+ historyMode: session.historyMode,
318
+ workingDirectory: session.ptyManager.workingDir,
319
+ buffer: session.buffer.getDebugSnapshot(),
320
+ textHistory: session.textHistory.getDebugSnapshot(),
321
+ renderedHistory: session.renderedHistory ? session.renderedHistory.getDebugSnapshot() : null,
322
+ ...extra
323
+ };
324
+ }
325
+
326
+ getCompactSessionDiagnostics(session, extra = {}) {
327
+ const buffer = session.buffer.getDebugSnapshot();
328
+ const textHistory = session.textHistory.getDebugSnapshot();
329
+ const renderedHistory = session.renderedHistory ? session.renderedHistory.getDebugSnapshot() : null;
330
+ return {
331
+ sessionId: session.id,
332
+ sessionName: session.name,
333
+ toolKey: session.tool.key,
334
+ historyMode: session.historyMode,
335
+ workingDirectory: session.ptyManager.workingDir,
336
+ buffer: {
337
+ items: buffer.items,
338
+ totalSize: buffer.totalSize,
339
+ currentSeq: buffer.currentSeq,
340
+ oldestSeq: buffer.oldestSeq,
341
+ newestSeq: buffer.newestSeq,
342
+ combinedTailPreview: buffer.combinedTailPreview
343
+ },
344
+ textHistory: {
345
+ lines: textHistory.lines,
346
+ bytes: textHistory.bytes,
347
+ totalWrites: textHistory.totalWrites,
348
+ totalBytes: textHistory.totalBytes,
349
+ escapeCount: textHistory.escapeCount,
350
+ clearEvents: textHistory.clearEvents,
351
+ eraseLineEvents: textHistory.eraseLineEvents,
352
+ cursorMoveEvents: textHistory.cursorMoveEvents,
353
+ trimEvents: textHistory.trimEvents,
354
+ tailPreview: textHistory.tailPreview
355
+ },
356
+ renderedHistory: renderedHistory ? {
357
+ cols: renderedHistory.cols,
358
+ rows: renderedHistory.rows,
359
+ totalWrites: renderedHistory.totalWrites,
360
+ totalBytes: renderedHistory.totalBytes,
361
+ pendingWrites: renderedHistory.pendingWrites,
362
+ resizeEvents: renderedHistory.resizeEvents,
363
+ bufferLines: renderedHistory.bufferLines,
364
+ baseY: renderedHistory.baseY,
365
+ cursorY: renderedHistory.cursorY,
366
+ cursorX: renderedHistory.cursorX,
367
+ tailPreview: renderedHistory.tailPreview
368
+ } : null,
369
+ ...extra
370
+ };
371
+ }
372
+
373
+ logSessionDiagnostics(reason, session, extra = {}, options = {}) {
374
+ if (!this.debugHistoryEnabled || !session) return;
375
+ const payload = options.compact
376
+ ? this.getCompactSessionDiagnostics(session, extra)
377
+ : this.getSessionDiagnostics(session, extra);
378
+ this.logger.debugInfo(`[history-debug] ${reason} ${JSON.stringify(payload)}`);
379
+ }
380
+ }
381
+
382
+ module.exports = SessionManager;
@@ -0,0 +1,274 @@
1
+ class TextHistory {
2
+ constructor(options = {}) {
3
+ this.maxBytes = options.maxBytes || 5 * 1024 * 1024;
4
+ this.debugLabel = options.debugLabel || 'session';
5
+ this.lines = [''];
6
+ this.row = 0;
7
+ this.col = 0;
8
+ this.updatedAt = Date.now();
9
+ this.truncated = false;
10
+ this.totalWrites = 0;
11
+ this.totalBytes = 0;
12
+ this.escapeCount = 0;
13
+ this.clearEvents = 0;
14
+ this.eraseLineEvents = 0;
15
+ this.cursorMoveEvents = 0;
16
+ this.trimEvents = 0;
17
+ this.lastEvents = [];
18
+ }
19
+
20
+ write(data) {
21
+ if (!data) return;
22
+ const text = String(data);
23
+ this.totalWrites += 1;
24
+ this.totalBytes += Buffer.byteLength(text, 'utf8');
25
+
26
+ for (let i = 0; i < text.length; i++) {
27
+ const ch = text[i];
28
+
29
+ if (ch === '\x1b') {
30
+ this.escapeCount += 1;
31
+ i = this.skipEscape(text, i);
32
+ continue;
33
+ }
34
+
35
+ if (ch === '\r') {
36
+ this.col = 0;
37
+ continue;
38
+ }
39
+
40
+ if (ch === '\n') {
41
+ this.newLine();
42
+ continue;
43
+ }
44
+
45
+ if (ch === '\b') {
46
+ this.col = Math.max(0, this.col - 1);
47
+ continue;
48
+ }
49
+
50
+ if (ch === '\t') {
51
+ const spaces = 4 - (this.col % 4);
52
+ for (let j = 0; j < spaces; j++) this.writeChar(' ');
53
+ continue;
54
+ }
55
+
56
+ if (ch >= ' ' || ch === '\u00a0') {
57
+ this.writeChar(ch);
58
+ }
59
+ }
60
+
61
+ this.updatedAt = Date.now();
62
+ this.trim();
63
+ }
64
+
65
+ toJSON() {
66
+ return {
67
+ text: this.toString(),
68
+ updatedAt: this.updatedAt,
69
+ truncated: this.truncated,
70
+ bytes: Buffer.byteLength(this.toString(), 'utf8'),
71
+ lines: this.lines.length
72
+ };
73
+ }
74
+
75
+ getDebugSnapshot(options = {}) {
76
+ const tailLines = options.tailLines || 12;
77
+ return {
78
+ label: this.debugLabel,
79
+ updatedAt: this.updatedAt,
80
+ truncated: this.truncated,
81
+ row: this.row,
82
+ col: this.col,
83
+ lines: this.lines.length,
84
+ bytes: Buffer.byteLength(this.lines.join('\n'), 'utf8'),
85
+ totalWrites: this.totalWrites,
86
+ totalBytes: this.totalBytes,
87
+ escapeCount: this.escapeCount,
88
+ clearEvents: this.clearEvents,
89
+ eraseLineEvents: this.eraseLineEvents,
90
+ cursorMoveEvents: this.cursorMoveEvents,
91
+ trimEvents: this.trimEvents,
92
+ lastEvents: [...this.lastEvents],
93
+ tailPreview: this.previewText(this.lines.slice(-tailLines).join('\n'))
94
+ };
95
+ }
96
+
97
+ toString() {
98
+ return this.lines.join('\n').replace(/\s+$/g, '');
99
+ }
100
+
101
+ writeChar(ch) {
102
+ this.ensureRow();
103
+ const line = this.lines[this.row] || '';
104
+ const padded = line.length < this.col ? line + ' '.repeat(this.col - line.length) : line;
105
+ this.lines[this.row] = padded.slice(0, this.col) + ch + padded.slice(this.col + 1);
106
+ this.col += 1;
107
+ }
108
+
109
+ newLine() {
110
+ this.row += 1;
111
+ this.col = 0;
112
+ this.ensureRow();
113
+ }
114
+
115
+ ensureRow() {
116
+ while (this.row >= this.lines.length) {
117
+ this.lines.push('');
118
+ }
119
+ }
120
+
121
+ skipEscape(text, index) {
122
+ const next = text[index + 1];
123
+
124
+ if (next === ']') {
125
+ return this.skipOsc(text, index + 2);
126
+ }
127
+
128
+ if (next === '[') {
129
+ return this.handleCsi(text, index + 2);
130
+ }
131
+
132
+ return Math.min(index + 1, text.length - 1);
133
+ }
134
+
135
+ skipOsc(text, index) {
136
+ for (let i = index; i < text.length; i++) {
137
+ if (text[i] === '\x07') return i;
138
+ if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
139
+ }
140
+ return text.length - 1;
141
+ }
142
+
143
+ handleCsi(text, index) {
144
+ let i = index;
145
+ while (i < text.length && !/[A-Za-z@`~]/.test(text[i])) {
146
+ i++;
147
+ }
148
+ if (i >= text.length) return text.length - 1;
149
+
150
+ const params = text.slice(index, i);
151
+ const command = text[i];
152
+ this.applyCsi(params, command);
153
+ return i;
154
+ }
155
+
156
+ applyCsi(params, command) {
157
+ const values = params
158
+ .replace(/[?>!]/g, '')
159
+ .split(';')
160
+ .filter(Boolean)
161
+ .map(value => Number.parseInt(value, 10))
162
+ .map(value => Number.isFinite(value) ? value : 0);
163
+ const first = values[0] || 0;
164
+
165
+ if (command === 'K') {
166
+ this.eraseLineEvents += 1;
167
+ this.recordEvent(`CSI K(${first})`);
168
+ this.eraseLine(first);
169
+ return;
170
+ }
171
+
172
+ if (command === 'J') {
173
+ if (first === 2 || first === 3) {
174
+ this.clearEvents += 1;
175
+ this.recordEvent(`CSI J(${first}) ignored-clear`);
176
+ this.startFreshLineAfterClear();
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (command === 'H' || command === 'f') {
182
+ this.cursorMoveEvents += 1;
183
+ this.recordEvent(`CSI ${command}(${params || ''}) ignored-cursor`);
184
+ return;
185
+ }
186
+
187
+ if (command === 'A') {
188
+ this.cursorMoveEvents += 1;
189
+ this.recordEvent(`CSI A(${first || 1}) ignored-cursor`);
190
+ return;
191
+ }
192
+
193
+ if (command === 'B') {
194
+ this.cursorMoveEvents += 1;
195
+ this.recordEvent(`CSI B(${first || 1}) ignored-cursor`);
196
+ return;
197
+ }
198
+
199
+ if (command === 'C') {
200
+ this.cursorMoveEvents += 1;
201
+ this.recordEvent(`CSI C(${first || 1}) ignored-cursor`);
202
+ return;
203
+ }
204
+
205
+ if (command === 'D') {
206
+ this.cursorMoveEvents += 1;
207
+ this.recordEvent(`CSI D(${first || 1}) ignored-cursor`);
208
+ return;
209
+ }
210
+
211
+ if (command === 'G') {
212
+ this.cursorMoveEvents += 1;
213
+ this.recordEvent(`CSI G(${first || 1}) ignored-cursor`);
214
+ }
215
+ }
216
+
217
+ eraseLine(mode) {
218
+ this.ensureRow();
219
+ const line = this.lines[this.row] || '';
220
+ if (mode === 1) {
221
+ this.lines[this.row] = ' '.repeat(Math.min(this.col, line.length)) + line.slice(this.col);
222
+ } else if (mode === 2) {
223
+ this.lines[this.row] = '';
224
+ this.col = 0;
225
+ } else {
226
+ this.lines[this.row] = line.slice(0, this.col);
227
+ }
228
+ }
229
+
230
+ startFreshLineAfterClear() {
231
+ const hasContent = this.lines.some(line => line.length > 0);
232
+ const currentLine = this.lines[this.row] || '';
233
+ if (hasContent && currentLine.length > 0) this.newLine();
234
+ this.row = this.lines.length - 1;
235
+ this.col = 0;
236
+ this.lines[this.row] = '';
237
+ }
238
+
239
+ trim() {
240
+ let bytes = Buffer.byteLength(this.lines.join('\n'), 'utf8');
241
+ while (bytes > this.maxBytes && this.lines.length > 1) {
242
+ const removed = this.lines.shift();
243
+ bytes -= Buffer.byteLength(removed, 'utf8') + 1;
244
+ this.row = Math.max(0, this.row - 1);
245
+ this.truncated = true;
246
+ this.trimEvents += 1;
247
+ }
248
+
249
+ if (bytes > this.maxBytes && this.lines.length === 1) {
250
+ const keepChars = Math.floor(this.maxBytes / 2);
251
+ this.lines[0] = this.lines[0].slice(-keepChars);
252
+ this.col = Math.min(this.col, this.lines[0].length);
253
+ this.truncated = true;
254
+ this.trimEvents += 1;
255
+ }
256
+ }
257
+
258
+ recordEvent(message) {
259
+ this.lastEvents.push(`${new Date().toISOString()} ${message}`);
260
+ if (this.lastEvents.length > 25) this.lastEvents.shift();
261
+ }
262
+
263
+ previewText(text, maxChars = 400) {
264
+ if (!text) return '';
265
+ const normalized = String(text)
266
+ .replace(/\r/g, '\\r')
267
+ .replace(/\n/g, '\\n')
268
+ .replace(/\t/g, '\\t')
269
+ .replace(/\x1b/g, '\\x1b');
270
+ return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
271
+ }
272
+ }
273
+
274
+ module.exports = TextHistory;