sema-cli 0.1.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.
@@ -0,0 +1,450 @@
1
+ const pty = require("node-pty-prebuilt-multiarch");
2
+ const os = require("node:os");
3
+ const { execSync } = require("node:child_process");
4
+ const { analyze } = require("./analyzer");
5
+ const { importPrivateKey, importPublicKey, deriveSharedSecret, deriveAesKey, encrypt, decrypt, computeFingerprint, generateKeyPair, exportPublicKey } = require("../shared/crypto");
6
+
7
+ const RELAY_URL = process.env.RELAY_URL || "ws://127.0.0.1:8787/ws";
8
+ const SESSION_ID = process.env.SESSION_ID;
9
+ const SESSION_TOKEN = process.env.SESSION_TOKEN;
10
+ const SHELL = process.env.SHELL || "/bin/zsh";
11
+ const CLI_COMMAND = process.env.CLI_COMMAND || "";
12
+ const INITIAL_COLS = Number(process.env.COLS || 80);
13
+ const INITIAL_ROWS = Number(process.env.ROWS || 24);
14
+ const RECONNECT_MS = 1000;
15
+ const IDLE_TIMEOUT_MS = 3000;
16
+ const BUFFER_MAX = 4096;
17
+
18
+ // E2E encryption keys (passed from CLI via env vars)
19
+ const MAC_PRIVATE_KEY = process.env.MAC_PRIVATE_KEY;
20
+ const MAC_PUBLIC_KEY = process.env.MAC_PUBLIC_KEY;
21
+ let privateKey = null;
22
+
23
+ if (MAC_PRIVATE_KEY && MAC_PUBLIC_KEY) {
24
+ try {
25
+ privateKey = importPrivateKey(MAC_PRIVATE_KEY);
26
+ console.log("[mac-agent] E2E encryption enabled");
27
+ } catch (err) {
28
+ console.error("[mac-agent] Warning: failed to import private key, E2E disabled:", err.message);
29
+ }
30
+ } else {
31
+ console.log("[mac-agent] E2E encryption disabled (no keys provided)");
32
+ }
33
+
34
+ // Per-device encryption state
35
+ const mobileKeys = new Map(); // deviceId → AES key (Buffer)
36
+ const mobilePendingKeys = new Map(); // deviceId → { oldKey, fingerprint, ttl }
37
+ const preKeyOutputBuffer = []; // output buffered before any key exchange
38
+ const PRE_KEY_MAX = 128;
39
+
40
+ // Terminal DA response patterns to filter from mobile input.
41
+ // The mobile's xterm.js auto-responds to DA queries from tmux/zsh —
42
+ // these must not reach the pty as shell input.
43
+ const DA_RESPONSE = /^\x1b\[\??[0-9;]*[a-zA-Z]$/;
44
+
45
+ // Validate required session parameters
46
+ if (!SESSION_ID || !SESSION_TOKEN) {
47
+ console.error("[mac-agent] Error: SESSION_ID and SESSION_TOKEN are required.");
48
+ console.error("[mac-agent] These are provided by the Sema CLI (npm run start -- <command>).");
49
+ console.error("[mac-agent] Or set them manually: SESSION_ID=<id> SESSION_TOKEN=<token> node agent.js");
50
+ process.exit(1);
51
+ }
52
+
53
+ const TMUX_NAME = `sema-${SESSION_ID}`;
54
+
55
+ // Alert state machine: idle → analyze → smart_alert (or skip)
56
+ let idleTimer = null;
57
+ let alertSent = false;
58
+ let lastAlertKey = null;
59
+ let outputBuffer = "";
60
+
61
+ let ws = null;
62
+ let connected = false;
63
+ const pendingOutput = [];
64
+
65
+ function tmuxSessionExists() {
66
+ try {
67
+ execSync(`tmux has-session -t ${TMUX_NAME} 2>/dev/null`);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function ensureTmuxSession() {
75
+ if (tmuxSessionExists()) {
76
+ console.log(`[mac-agent] tmux session "${TMUX_NAME}" exists, will reattach`);
77
+ return;
78
+ }
79
+ const cmd = CLI_COMMAND || SHELL;
80
+ execSync(
81
+ `tmux new-session -d -s ${TMUX_NAME} -x ${INITIAL_COLS} -y ${INITIAL_ROWS} ${JSON.stringify(cmd)}`,
82
+ {
83
+ env: { ...process.env, TERM: "xterm-256color", SEMA_SESSION_ID: SESSION_ID },
84
+ },
85
+ );
86
+ console.log(`[mac-agent] created tmux session "${TMUX_NAME}" running: ${cmd}`);
87
+ }
88
+
89
+ ensureTmuxSession();
90
+
91
+ const ptyProcess = pty.spawn("tmux", ["attach-session", "-t", TMUX_NAME], {
92
+ name: "xterm-256color",
93
+ cols: INITIAL_COLS,
94
+ rows: INITIAL_ROWS,
95
+ cwd: process.cwd(),
96
+ env: { ...process.env, TERM: "xterm-256color" },
97
+ });
98
+
99
+ function relayUrl() {
100
+ const url = new URL(RELAY_URL);
101
+ url.searchParams.set("role", "mac");
102
+ url.searchParams.set("sessionId", SESSION_ID);
103
+ url.searchParams.set("sessionToken", SESSION_TOKEN);
104
+ return url.toString();
105
+ }
106
+
107
+ function send(message) {
108
+ const data = JSON.stringify(message);
109
+ if (connected && ws && ws.readyState === WebSocket.OPEN) {
110
+ ws.send(data);
111
+ return;
112
+ }
113
+ if (message.type === "output" || message.type === "alert" || message.type === "smart_alert") pendingOutput.push(data);
114
+ }
115
+
116
+ function flushPendingOutput() {
117
+ while (pendingOutput.length && ws && ws.readyState === WebSocket.OPEN) {
118
+ ws.send(pendingOutput.shift());
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Decrypt a message from a specific device, trying current key first,
124
+ * then falling back to pending old key (during key rotation).
125
+ * @returns {string|null} decrypted plaintext or null on failure
126
+ */
127
+ function decryptFromDevice(deviceId, encrypted) {
128
+ const currentKey = mobileKeys.get(deviceId);
129
+ if (currentKey) {
130
+ try { return decrypt(encrypted, currentKey); } catch {}
131
+ }
132
+ const pending = mobilePendingKeys.get(deviceId);
133
+ if (pending?.oldKey) {
134
+ try { return decrypt(encrypted, pending.oldKey); } catch {}
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function connect() {
140
+ ws = new WebSocket(relayUrl());
141
+
142
+ ws.addEventListener("open", () => {
143
+ connected = true;
144
+ console.log(`[mac-agent] connected session=${SESSION_ID}`);
145
+ send({
146
+ type: "output",
147
+ sessionId: SESSION_ID,
148
+ data: `\r\n\x1b[1;32m[Sema Mac Agent connected on ${os.hostname()}]\x1b[0m\r\n`,
149
+ });
150
+ flushPendingOutput();
151
+ });
152
+
153
+ ws.addEventListener("message", (event) => {
154
+ try {
155
+ const message = JSON.parse(event.data);
156
+
157
+ // Key exchange: mobile sends its public key
158
+ if (message.type === "key_exchange") {
159
+ if (!privateKey) {
160
+ console.error("[mac-agent] key_exchange received but E2E is disabled");
161
+ return;
162
+ }
163
+ try {
164
+ const peerPublicKey = importPublicKey(message.publicKey);
165
+
166
+ // Phase 1: Derive initial shared secret (K_old)
167
+ const sharedSecret = deriveSharedSecret(privateKey, peerPublicKey);
168
+ const aesKey = deriveAesKey(sharedSecret);
169
+
170
+ // Send encrypted key_ack with K_old
171
+ const ackPayload = encrypt(JSON.stringify({ type: "key_ack" }), aesKey);
172
+ send({
173
+ type: "key_ack",
174
+ sessionId: SESSION_ID,
175
+ deviceId: message.deviceId,
176
+ ...ackPayload,
177
+ });
178
+
179
+ // Phase 2: Generate ephemeral keypair for this device (forward secrecy)
180
+ const ephKeyPair = generateKeyPair();
181
+ const ephSharedSecret = deriveSharedSecret(ephKeyPair.privateKey, peerPublicKey);
182
+ const ephAesKey = deriveAesKey(ephSharedSecret);
183
+ const ephFingerprint = computeFingerprint(ephSharedSecret);
184
+
185
+ // Send key_rotation encrypted with K_old (protects ephemeral public key)
186
+ const ephPubB64 = exportPublicKey(ephKeyPair.publicKey);
187
+ const rotPayload = encrypt(JSON.stringify({
188
+ type: "key_rotation",
189
+ publicKey: ephPubB64,
190
+ }), aesKey);
191
+ send({
192
+ type: "key_rotation",
193
+ sessionId: SESSION_ID,
194
+ deviceId: message.deviceId,
195
+ ...rotPayload,
196
+ });
197
+
198
+ // Store: new key for subsequent traffic, old key pending rot_ack
199
+ mobileKeys.set(message.deviceId, ephAesKey);
200
+ const ttl = setTimeout(() => {
201
+ mobilePendingKeys.delete(message.deviceId);
202
+ console.log(`[mac-agent] key rotation timeout: device=${message.deviceId}`);
203
+ }, 30_000);
204
+ mobilePendingKeys.set(message.deviceId, { oldKey: aesKey, fingerprint: ephFingerprint, ttl });
205
+
206
+ // Display fingerprint in terminal for user verification
207
+ console.log(`[mac-agent] 🔐 Fingerprint for device ${message.deviceId.slice(0, 8)}: ${ephFingerprint}`);
208
+
209
+ // Flush pre-key buffer to this device (encrypted with new key)
210
+ for (const buffered of preKeyOutputBuffer) {
211
+ const plaintext = JSON.stringify({ type: "output", data: buffered });
212
+ const enc = encrypt(plaintext, ephAesKey);
213
+ send({
214
+ type: "output",
215
+ sessionId: SESSION_ID,
216
+ deviceId: message.deviceId,
217
+ ...enc,
218
+ });
219
+ }
220
+ } catch (err) {
221
+ console.error("[mac-agent] key exchange failed:", err.message);
222
+ }
223
+ return;
224
+ }
225
+
226
+ // Key rotation ack: mobile confirms it has the new key (must be encrypted)
227
+ if (message.type === "key_rot_ack") {
228
+ if (!message.deviceId || !message.iv || !message.ct || !message.tag) {
229
+ console.error("[mac-agent] key_rot_ack missing encryption fields, rejected");
230
+ return;
231
+ }
232
+ const plaintext = decryptFromDevice(message.deviceId, {
233
+ iv: message.iv, ct: message.ct, tag: message.tag,
234
+ });
235
+ if (!plaintext) {
236
+ console.error("[mac-agent] key_rot_ack decryption failed, rejected");
237
+ return;
238
+ }
239
+ const pending = mobilePendingKeys.get(message.deviceId);
240
+ if (pending) {
241
+ clearTimeout(pending.ttl);
242
+ mobilePendingKeys.delete(message.deviceId);
243
+ }
244
+ console.log(`[mac-agent] key rotation complete: device=${message.deviceId}`);
245
+ return;
246
+ }
247
+
248
+ // Device revoked: drop key for this device
249
+ if (message.type === "device_revoked") {
250
+ mobileKeys.delete(message.deviceId);
251
+ console.log(`[mac-agent] device revoked: ${message.deviceId}`);
252
+ return;
253
+ }
254
+
255
+ // Encrypted input
256
+ if (message.type === "input") {
257
+ // Try to decrypt if E2E is enabled and deviceId is present
258
+ if (privateKey && message.deviceId && message.iv && message.ct && message.tag) {
259
+ const plaintext = decryptFromDevice(message.deviceId, {
260
+ iv: message.iv, ct: message.ct, tag: message.tag,
261
+ });
262
+ if (!plaintext) {
263
+ console.error("[mac-agent] input decryption failed: no matching key");
264
+ return;
265
+ }
266
+ try {
267
+ const decrypted = JSON.parse(plaintext);
268
+ const input = decrypted.data || "";
269
+ if (input && !DA_RESPONSE.test(input)) {
270
+ ptyProcess.write(input);
271
+ }
272
+ } catch (err) {
273
+ console.error("[mac-agent] input parse failed:", err.message);
274
+ }
275
+ return;
276
+ }
277
+
278
+ // Plaintext fallback (no E2E)
279
+ const input = message.data || "";
280
+ if (input && !DA_RESPONSE.test(input)) {
281
+ ptyProcess.write(input);
282
+ }
283
+ return;
284
+ }
285
+
286
+ // Encrypted resize
287
+ if (message.type === "resize") {
288
+ if (privateKey && message.deviceId && message.iv && message.ct && message.tag) {
289
+ const plaintext = decryptFromDevice(message.deviceId, {
290
+ iv: message.iv, ct: message.ct, tag: message.tag,
291
+ });
292
+ if (!plaintext) return;
293
+ try {
294
+ const decrypted = JSON.parse(plaintext);
295
+ const cols = Number(decrypted.cols) || INITIAL_COLS;
296
+ const rows = Number(decrypted.rows) || INITIAL_ROWS;
297
+ ptyProcess.resize(cols, rows);
298
+ console.log(`[mac-agent] resized to ${cols}x${rows}`);
299
+ } catch (err) {
300
+ console.error("[mac-agent] resize parse failed:", err.message);
301
+ }
302
+ return;
303
+ }
304
+
305
+ // Plaintext fallback
306
+ const cols = Number(message.cols) || INITIAL_COLS;
307
+ const rows = Number(message.rows) || INITIAL_ROWS;
308
+ ptyProcess.resize(cols, rows);
309
+ console.log(`[mac-agent] resized to ${cols}x${rows}`);
310
+ return;
311
+ }
312
+ } catch (error) {
313
+ console.error("[mac-agent] invalid relay message");
314
+ }
315
+ });
316
+
317
+ ws.addEventListener("close", () => {
318
+ if (connected) console.log("[mac-agent] disconnected, retrying...");
319
+ connected = false;
320
+ // Keep device keys — mobile reconnect will trigger new key_exchange
321
+ // Old keys retained for potential key_rotation with re-exchanged devices
322
+ setTimeout(connect, RECONNECT_MS);
323
+ });
324
+
325
+ ws.addEventListener("error", () => {
326
+ connected = false;
327
+ });
328
+ }
329
+
330
+ function onIdle() {
331
+ if (alertSent) return;
332
+ alertSent = true;
333
+
334
+ const result = analyze(outputBuffer);
335
+
336
+ if (result && result.confidence >= 0.7) {
337
+ // Dedup: skip if same question was already alerted
338
+ const alertKey = `${result.kind}:${result.question}`;
339
+ if (alertKey === lastAlertKey) {
340
+ console.log(`[mac-agent] duplicate alert, skipping`);
341
+ return;
342
+ }
343
+ lastAlertKey = alertKey;
344
+
345
+ console.log(`[mac-agent] smart alert: ${result.kind} (${result.confidence})`);
346
+
347
+ const alertMessage = {
348
+ type: "smart_alert",
349
+ sessionId: SESSION_ID,
350
+ kind: result.kind,
351
+ question: result.question,
352
+ options: result.options,
353
+ context: result.context,
354
+ confidence: result.confidence,
355
+ };
356
+
357
+ // E2E: encrypt for each device
358
+ if (privateKey && mobileKeys.size > 0) {
359
+ for (const [deviceId, aesKey] of mobileKeys) {
360
+ try {
361
+ const plaintext = JSON.stringify(alertMessage);
362
+ const enc = encrypt(plaintext, aesKey);
363
+ send({
364
+ type: "smart_alert",
365
+ sessionId: SESSION_ID,
366
+ deviceId,
367
+ ...enc,
368
+ });
369
+ } catch (err) {
370
+ console.error(`[mac-agent] smart_alert encrypt failed:`, err.message);
371
+ }
372
+ }
373
+ } else {
374
+ // Plaintext mode
375
+ send(alertMessage);
376
+ }
377
+ } else {
378
+ // Quality gate: unrecognized idle → no notification
379
+ console.log(`[mac-agent] idle, no pattern matched — skipping alert`);
380
+ }
381
+ }
382
+
383
+ ptyProcess.onData((data) => {
384
+ // E2E: encrypt output for each connected device
385
+ if (privateKey && mobileKeys.size > 0) {
386
+ for (const [deviceId, aesKey] of mobileKeys) {
387
+ try {
388
+ const plaintext = JSON.stringify({ type: "output", data });
389
+ const enc = encrypt(plaintext, aesKey);
390
+ send({
391
+ type: "output",
392
+ sessionId: SESSION_ID,
393
+ deviceId,
394
+ ...enc,
395
+ });
396
+ } catch (err) {
397
+ console.error(`[mac-agent] encrypt failed for device ${deviceId}:`, err.message);
398
+ }
399
+ }
400
+ } else if (privateKey) {
401
+ // E2E enabled but no devices connected yet — buffer
402
+ if (preKeyOutputBuffer.length < PRE_KEY_MAX) {
403
+ preKeyOutputBuffer.push(data);
404
+ }
405
+ } else {
406
+ // Plaintext mode: send directly
407
+ send({
408
+ type: "output",
409
+ sessionId: SESSION_ID,
410
+ data,
411
+ });
412
+ }
413
+
414
+ // Accumulate output buffer for semantic analysis
415
+ outputBuffer += data;
416
+ if (outputBuffer.length > BUFFER_MAX) {
417
+ outputBuffer = outputBuffer.slice(outputBuffer.length - BUFFER_MAX);
418
+ }
419
+
420
+ // Reset alert state on new output
421
+ alertSent = false;
422
+ lastAlertKey = null;
423
+ clearTimeout(idleTimer);
424
+
425
+ // Start idle timer — if no output for IDLE_TIMEOUT_MS, analyze
426
+ idleTimer = setTimeout(onIdle, IDLE_TIMEOUT_MS);
427
+ });
428
+
429
+ ptyProcess.onExit(({ exitCode }) => {
430
+ send({
431
+ type: "output",
432
+ sessionId: SESSION_ID,
433
+ data: `\r\n\x1b[1;31m[Sema detached from tmux session]\x1b[0m\r\n`,
434
+ });
435
+ process.exit(exitCode || 0);
436
+ });
437
+
438
+ process.on("SIGINT", () => {
439
+ ptyProcess.kill();
440
+ process.exit(0);
441
+ });
442
+
443
+ process.on("SIGTERM", () => {
444
+ ptyProcess.kill();
445
+ process.exit(0);
446
+ });
447
+
448
+ const modeLabel = CLI_COMMAND ? `cli=${CLI_COMMAND}` : `shell=${SHELL}`;
449
+ console.log(`[mac-agent] tmux=${TMUX_NAME} ${modeLabel} session=${SESSION_ID} size=${INITIAL_COLS}x${INITIAL_ROWS}`);
450
+ connect();
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+
3
+ // --- ANSI stripping ---
4
+
5
+ function stripAnsi(str) {
6
+ return str
7
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") // CSI sequences (colors, cursor, etc.)
8
+ .replace(/\x1b\][^\x07]*\x07/g, "") // OSC sequences (title, etc.)
9
+ .replace(/\x1b[()][AB012]/g, "") // charset select
10
+ .replace(/\x1b[=>]/g, "") // keypad mode
11
+ .replace(/\x1b\[\?[0-9;]*[hl]/g, "") // private mode set/reset
12
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // control chars (keep \t\n\r)
13
+ .replace(/\r/g, "") // CR (keep LF only)
14
+ .trimEnd();
15
+ }
16
+
17
+ // --- Pattern matchers ---
18
+
19
+ const YESNO_RE = /[\(\[]\s*[yYnN]\s*\/\s*[yYnN]\s*[\)\]]/;
20
+
21
+ function matchYesNo(clean, lines) {
22
+ if (!YESNO_RE.test(clean)) return null;
23
+
24
+ // Find the last non-empty line containing the y/n pattern
25
+ let questionLine = "";
26
+ let questionIdx = -1;
27
+ for (let i = lines.length - 1; i >= 0; i--) {
28
+ if (YESNO_RE.test(lines[i])) {
29
+ questionLine = lines[i].trim();
30
+ questionIdx = i;
31
+ break;
32
+ }
33
+ }
34
+ if (!questionLine) return null;
35
+
36
+ // Context: 2-3 lines before the question
37
+ const contextLines = lines.slice(Math.max(0, questionIdx - 3), questionIdx);
38
+ const context = contextLines.join("\n").slice(0, 300);
39
+
40
+ return {
41
+ kind: "yesno",
42
+ question: questionLine.slice(0, 200),
43
+ options: [
44
+ { label: "Yes", data: "y\n" },
45
+ { label: "No", data: "n\n" },
46
+ ],
47
+ context,
48
+ confidence: 0.9,
49
+ };
50
+ }
51
+
52
+ const CHOICE_LINE_RE = /^\s*\d+[\.\)]\s+.+/;
53
+
54
+ function matchChoice(lines) {
55
+ // Find consecutive lines matching numbered choice pattern
56
+ const choiceIndices = [];
57
+ for (let i = 0; i < lines.length; i++) {
58
+ if (CHOICE_LINE_RE.test(lines[i])) {
59
+ choiceIndices.push(i);
60
+ }
61
+ }
62
+
63
+ // Need at least 2 choice lines
64
+ if (choiceIndices.length < 2) return null;
65
+
66
+ // Check that at least some are consecutive (within 1 line gap)
67
+ let consecutiveCount = 1;
68
+ let maxConsecutive = 1;
69
+ for (let i = 1; i < choiceIndices.length; i++) {
70
+ if (choiceIndices[i] - choiceIndices[i - 1] <= 2) {
71
+ consecutiveCount++;
72
+ maxConsecutive = Math.max(maxConsecutive, consecutiveCount);
73
+ } else {
74
+ consecutiveCount = 1;
75
+ }
76
+ }
77
+ if (maxConsecutive < 2) return null;
78
+
79
+ // Extract options
80
+ const options = [];
81
+ for (const idx of choiceIndices) {
82
+ const line = lines[idx].trim();
83
+ const numMatch = line.match(/^\s*(\d+)/);
84
+ if (numMatch) {
85
+ options.push({
86
+ label: line.slice(0, 60),
87
+ data: numMatch[1] + "\n",
88
+ });
89
+ }
90
+ }
91
+
92
+ // Add "Other" option for custom input
93
+ options.push({ label: "Other", data: "" });
94
+
95
+ // Question: last non-choice line before the choices
96
+ const firstChoiceIdx = choiceIndices[0];
97
+ let question = "";
98
+ for (let i = firstChoiceIdx - 1; i >= 0; i--) {
99
+ if (!CHOICE_LINE_RE.test(lines[i]) && lines[i].trim()) {
100
+ question = lines[i].trim();
101
+ break;
102
+ }
103
+ }
104
+
105
+ // Context: lines before the question
106
+ const contextStart = Math.max(0, firstChoiceIdx - 4);
107
+ const contextEnd = firstChoiceIdx;
108
+ const contextLines = lines.slice(contextStart, contextEnd).filter(
109
+ (l) => l.trim() && !CHOICE_LINE_RE.test(l)
110
+ );
111
+ const context = contextLines.join("\n").slice(0, 300);
112
+
113
+ return {
114
+ kind: "choice",
115
+ question: question.slice(0, 200) || "Select an option",
116
+ options,
117
+ context,
118
+ confidence: 0.8,
119
+ };
120
+ }
121
+
122
+ const ERROR_PATTERNS = [
123
+ /^\s*Error[: ]/i,
124
+ /\bfailed\b/i,
125
+ /permission denied/i,
126
+ /\bENOENT\b/,
127
+ /command not found/i,
128
+ /exit(?:ed)? with (?:code|status) [1-9]/,
129
+ /[✗✘❌]/,
130
+ ];
131
+
132
+ const ERROR_EXCLUSIONS = [
133
+ /0\s*errors?/i,
134
+ /no\s+error/i,
135
+ /error\s*(?:rate|count).*[:=]\s*0/i,
136
+ /\bsuccess\b/i,
137
+ ];
138
+
139
+ function isErrorLine(line) {
140
+ // Check exclusions first
141
+ for (const excl of ERROR_EXCLUSIONS) {
142
+ if (excl.test(line)) return false;
143
+ }
144
+ // Check positive patterns
145
+ for (const pat of ERROR_PATTERNS) {
146
+ if (pat.test(line)) return true;
147
+ }
148
+ return false;
149
+ }
150
+
151
+ function matchError(lines) {
152
+ // Scan from bottom up — find the last error line
153
+ for (let i = lines.length - 1; i >= 0; i--) {
154
+ if (isErrorLine(lines[i])) {
155
+ const question = lines[i].trim().slice(0, 200);
156
+
157
+ // Context: 2 lines before
158
+ const contextLines = lines.slice(Math.max(0, i - 2), i);
159
+ const context = contextLines.join("\n").slice(0, 300);
160
+
161
+ return {
162
+ kind: "error",
163
+ question,
164
+ options: [{ label: "Acknowledge", data: "\r" }],
165
+ context,
166
+ confidence: 0.7,
167
+ };
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ // --- Main analyze function ---
174
+
175
+ function analyze(rawOutput) {
176
+ if (!rawOutput || !rawOutput.trim()) return null;
177
+
178
+ // Unified truncation at entry — 512 chars from the tail
179
+ const tail = rawOutput.slice(-512);
180
+ const clean = stripAnsi(tail);
181
+ const lines = clean.split("\n").filter((l) => l.trim());
182
+
183
+ if (lines.length === 0) return null;
184
+
185
+ // Match order: most specific → most general
186
+ return matchYesNo(clean, lines) || matchChoice(lines) || matchError(lines);
187
+ }
188
+
189
+ module.exports = { analyze, stripAnsi };