codmir 0.3.3 → 0.4.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,1046 @@
1
+ import {
2
+ __require
3
+ } from "../chunk-EBO3CZXG.mjs";
4
+
5
+ // src/voice-daemon/logger.ts
6
+ var LOG_LEVELS = ["debug", "info", "warn", "error"];
7
+ function normalizeLogLevel(value) {
8
+ if (!value) return "info";
9
+ const lower = value.toLowerCase();
10
+ if (LOG_LEVELS.includes(lower)) {
11
+ return lower;
12
+ }
13
+ return "info";
14
+ }
15
+ var Logger = class {
16
+ constructor(level, prefix = "codmir-voice") {
17
+ this.level = level;
18
+ this.prefix = prefix;
19
+ }
20
+ /**
21
+ * Logs a debug-level message.
22
+ */
23
+ debug(message, meta) {
24
+ this.log("debug", message, meta);
25
+ }
26
+ /**
27
+ * Logs an info-level message.
28
+ */
29
+ info(message, meta) {
30
+ this.log("info", message, meta);
31
+ }
32
+ /**
33
+ * Logs a warning-level message.
34
+ */
35
+ warn(message, meta) {
36
+ this.log("warn", message, meta);
37
+ }
38
+ /**
39
+ * Logs an error-level message.
40
+ */
41
+ error(message, meta) {
42
+ this.log("error", message, meta);
43
+ }
44
+ log(level, message, meta) {
45
+ const currentIndex = LOG_LEVELS.indexOf(this.level);
46
+ const messageIndex = LOG_LEVELS.indexOf(level);
47
+ if (messageIndex < currentIndex) return;
48
+ const time = (/* @__PURE__ */ new Date()).toISOString();
49
+ const levelTag = level.toUpperCase().padEnd(5);
50
+ const base = `[${time}] [${this.prefix}] [${levelTag}] ${message}`;
51
+ if (meta !== void 0) {
52
+ console.log(`${base} ${JSON.stringify(meta)}`);
53
+ } else {
54
+ console.log(base);
55
+ }
56
+ }
57
+ };
58
+
59
+ // src/voice-daemon/config.ts
60
+ function loadConfig(logger) {
61
+ const portEnv = process.env.CODMIR_VOICE_PORT;
62
+ const httpPortEnv = process.env.CODMIR_VOICE_HTTP_PORT;
63
+ const port = parsePort(portEnv, 9410, logger, "CODMIR_VOICE_PORT");
64
+ const httpPort = parsePort(httpPortEnv, 9411, logger, "CODMIR_VOICE_HTTP_PORT");
65
+ const logLevel = normalizeLogLevel(process.env.CODMIR_VOICE_LOG_LEVEL);
66
+ const desktopAutomationUrl = process.env.DESKTOP_AUTOMATION_URL || "http://localhost:8095";
67
+ const ttsWorkerUrl = process.env.TTS_WORKER_URL || "http://localhost:8094";
68
+ const orchestratorUrl = process.env.ORCHESTRATOR_URL || "http://localhost:8096";
69
+ const overlayUrl = process.env.OVERLAY_URL || "http://localhost:9412";
70
+ const approvalMode = process.env.CODMIR_APPROVAL_MODE !== "0";
71
+ const muted = process.env.CODMIR_MUTED === "1";
72
+ return {
73
+ port,
74
+ httpPort,
75
+ logLevel,
76
+ desktopAutomationUrl,
77
+ ttsWorkerUrl,
78
+ orchestratorUrl,
79
+ overlayUrl,
80
+ approvalMode,
81
+ muted
82
+ };
83
+ }
84
+ function parsePort(value, defaultValue, logger, envName) {
85
+ if (!value) return defaultValue;
86
+ const parsed = Number(value);
87
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 65535) {
88
+ logger.warn(`Invalid ${envName} '${value}', using default ${defaultValue}`);
89
+ return defaultValue;
90
+ }
91
+ return parsed;
92
+ }
93
+
94
+ // src/voice-daemon/state-machine.ts
95
+ var AssistantStateMachine = class {
96
+ constructor(logger) {
97
+ this.state = "idle";
98
+ this.listeners = /* @__PURE__ */ new Set();
99
+ this.logger = logger;
100
+ }
101
+ /**
102
+ * Returns the current assistant state.
103
+ */
104
+ getState() {
105
+ return this.state;
106
+ }
107
+ /**
108
+ * Transitions to a new state and notifies listeners.
109
+ */
110
+ setState(newState) {
111
+ if (newState === this.state) return;
112
+ const previous = this.state;
113
+ this.state = newState;
114
+ this.logger.info("State change", { from: previous, to: newState });
115
+ for (const callback of this.listeners) {
116
+ try {
117
+ callback(newState, previous);
118
+ } catch (error) {
119
+ this.logger.warn("State listener error", { error: String(error) });
120
+ }
121
+ }
122
+ }
123
+ /**
124
+ * Registers a callback to be invoked on state changes.
125
+ */
126
+ onStateChange(callback) {
127
+ this.listeners.add(callback);
128
+ return () => {
129
+ this.listeners.delete(callback);
130
+ };
131
+ }
132
+ /**
133
+ * Called when wake word is detected.
134
+ */
135
+ handleWake() {
136
+ if (this.state === "idle") {
137
+ this.setState("listening");
138
+ }
139
+ }
140
+ /**
141
+ * Called when speech-to-text is complete.
142
+ */
143
+ handleTranscriptComplete() {
144
+ if (this.state === "listening") {
145
+ this.setState("thinking");
146
+ }
147
+ }
148
+ /**
149
+ * Called when AI response is ready and TTS begins.
150
+ */
151
+ handleSpeakStart() {
152
+ if (this.state === "thinking") {
153
+ this.setState("speaking");
154
+ }
155
+ }
156
+ /**
157
+ * Called when TTS playback is complete.
158
+ */
159
+ handleSpeakEnd() {
160
+ if (this.state === "speaking") {
161
+ this.setState("idle");
162
+ }
163
+ }
164
+ /**
165
+ * Called on any error, resets to idle.
166
+ */
167
+ handleError() {
168
+ this.setState("error");
169
+ setTimeout(() => {
170
+ if (this.state === "error") {
171
+ this.setState("idle");
172
+ }
173
+ }, 3e3);
174
+ }
175
+ /**
176
+ * Force reset to idle.
177
+ */
178
+ reset() {
179
+ this.setState("idle");
180
+ }
181
+ };
182
+
183
+ // src/voice-daemon/command-executor.ts
184
+ var CommandExecutor = class {
185
+ constructor(config, logger) {
186
+ this.config = config;
187
+ this.logger = logger;
188
+ }
189
+ /**
190
+ * Executes a single command.
191
+ */
192
+ async execute(command) {
193
+ this.logger.info("Executing command", { type: command.type });
194
+ try {
195
+ switch (command.type) {
196
+ case "open_app":
197
+ return this.callDesktopAutomation("/launch/app", { cmd: command.name });
198
+ case "open_url":
199
+ return this.callDesktopAutomation("/open/url", { url: command.url });
200
+ case "focus_window":
201
+ return this.callDesktopAutomation("/window/activate", {
202
+ name_contains: command.titleContains
203
+ });
204
+ case "type_text":
205
+ return this.callDesktopAutomation("/type", { text: command.text });
206
+ case "press_key":
207
+ return this.callDesktopAutomation("/key", { sequence: command.key });
208
+ case "click":
209
+ return this.callDesktopAutomation("/click", { button: command.button ?? 1 });
210
+ case "scroll":
211
+ const btn = command.direction === "up" ? 4 : 5;
212
+ const clicks = command.amount ?? 3;
213
+ for (let i = 0; i < clicks; i++) {
214
+ await this.callDesktopAutomation("/click", { button: btn });
215
+ }
216
+ return { success: true };
217
+ case "speak":
218
+ return this.callTts(command.text, command.voice);
219
+ case "run_script":
220
+ this.logger.warn("run_script not implemented for security reasons");
221
+ return { success: false, error: "run_script disabled" };
222
+ default: {
223
+ const exhaustive = command;
224
+ return { success: false, error: `Unknown command type: ${exhaustive.type}` };
225
+ }
226
+ }
227
+ } catch (error) {
228
+ const message = error instanceof Error ? error.message : String(error);
229
+ this.logger.error("Command execution failed", { command, error: message });
230
+ return { success: false, error: message };
231
+ }
232
+ }
233
+ async callDesktopAutomation(endpoint, body) {
234
+ const url = `${this.config.desktopAutomationUrl}${endpoint}`;
235
+ const response = await fetch(url, {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify(body)
239
+ });
240
+ if (!response.ok) {
241
+ const text = await response.text();
242
+ return { success: false, error: `HTTP ${response.status}: ${text}` };
243
+ }
244
+ return { success: true };
245
+ }
246
+ async callTts(text, voice) {
247
+ const url = `${this.config.ttsWorkerUrl}/speak`;
248
+ const body = { text };
249
+ if (voice) body.voice = voice;
250
+ const response = await fetch(url, {
251
+ method: "POST",
252
+ headers: { "Content-Type": "application/json" },
253
+ body: JSON.stringify(body)
254
+ });
255
+ if (!response.ok) {
256
+ const errText = await response.text();
257
+ return { success: false, error: `TTS failed: ${errText}` };
258
+ }
259
+ this.logger.info("TTS audio generated", { textLength: text.length });
260
+ return { success: true };
261
+ }
262
+ };
263
+
264
+ // src/voice-daemon/ws-server.ts
265
+ import os from "os";
266
+ import { WebSocketServer, WebSocket } from "ws";
267
+ var DAEMON_VERSION = "0.1.0";
268
+ var VoiceDaemonWebSocketServer = class {
269
+ constructor(config, logger, stateMachine, executor) {
270
+ this.clients = /* @__PURE__ */ new Set();
271
+ this.config = config;
272
+ this.logger = logger;
273
+ this.stateMachine = stateMachine;
274
+ this.executor = executor;
275
+ this.wss = new WebSocketServer({
276
+ port: this.config.port,
277
+ host: "127.0.0.1"
278
+ });
279
+ this.setupListeners();
280
+ this.setupStateListener();
281
+ }
282
+ setupListeners() {
283
+ this.wss.on("listening", () => {
284
+ this.logger.info("WebSocket server listening", { port: this.config.port });
285
+ });
286
+ this.wss.on("connection", (socket) => {
287
+ const client = { socket };
288
+ this.clients.add(client);
289
+ this.logger.info("Client connected", { total: this.clients.size });
290
+ this.sendDaemonStarted(client);
291
+ socket.on("message", (data) => {
292
+ this.handleMessage(client, data.toString("utf8"));
293
+ });
294
+ socket.on("close", () => {
295
+ this.clients.delete(client);
296
+ this.logger.info("Client disconnected", { total: this.clients.size });
297
+ });
298
+ socket.on("error", (error) => {
299
+ this.logger.warn("WebSocket client error", { error: String(error) });
300
+ });
301
+ });
302
+ this.wss.on("error", (error) => {
303
+ this.logger.error("WebSocket server error", { error: String(error) });
304
+ });
305
+ }
306
+ setupStateListener() {
307
+ this.stateMachine.onStateChange((newState, previousState) => {
308
+ this.broadcast({
309
+ type: "state_change",
310
+ state: newState,
311
+ previousState,
312
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
313
+ });
314
+ });
315
+ }
316
+ sendDaemonStarted(client) {
317
+ const message = {
318
+ type: "daemon_started",
319
+ version: DAEMON_VERSION,
320
+ hostname: os.hostname(),
321
+ platform: process.platform,
322
+ pid: process.pid,
323
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
324
+ };
325
+ this.sendToClient(client, message);
326
+ }
327
+ handleMessage(client, raw) {
328
+ let parsed;
329
+ try {
330
+ parsed = JSON.parse(raw);
331
+ } catch {
332
+ this.logger.warn("Failed to parse client message", { raw: raw.slice(0, 100) });
333
+ return;
334
+ }
335
+ switch (parsed.type) {
336
+ case "ping":
337
+ this.sendToClient(client, {
338
+ type: "pong",
339
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
340
+ });
341
+ return;
342
+ case "identify":
343
+ client.id = parsed.clientId;
344
+ client.type = parsed.clientType;
345
+ this.logger.info("Client identified", { id: client.id, type: client.type });
346
+ return;
347
+ case "wake_detected":
348
+ this.handleWakeEvent(parsed.wakeword);
349
+ return;
350
+ case "set_state":
351
+ this.stateMachine.setState(parsed.state);
352
+ return;
353
+ case "execute_command":
354
+ this.handleExecuteCommand(parsed.command);
355
+ return;
356
+ case "mute":
357
+ this.logger.info("Mute requested");
358
+ return;
359
+ case "unmute":
360
+ this.logger.info("Unmute requested");
361
+ return;
362
+ default: {
363
+ const exhaustive = parsed;
364
+ this.logger.warn("Unknown message type", { type: exhaustive.type });
365
+ }
366
+ }
367
+ }
368
+ /**
369
+ * Called when wake word is detected (from wakeword-listener webhook or client).
370
+ */
371
+ handleWakeEvent(wakeword) {
372
+ if (this.config.muted) {
373
+ this.logger.debug("Ignoring wake event (muted)");
374
+ return;
375
+ }
376
+ this.logger.info("Wake word detected", { wakeword });
377
+ this.stateMachine.handleWake();
378
+ this.broadcast({
379
+ type: "wake",
380
+ wakeword,
381
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
382
+ });
383
+ }
384
+ async handleExecuteCommand(command) {
385
+ const result = await this.executor.execute(command);
386
+ this.broadcast({
387
+ type: "command_executed",
388
+ command,
389
+ success: result.success,
390
+ error: result.error,
391
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
392
+ });
393
+ }
394
+ /**
395
+ * Broadcasts a transcript update to all clients.
396
+ */
397
+ broadcastTranscript(text, isFinal) {
398
+ this.broadcast({
399
+ type: "transcript",
400
+ text,
401
+ isFinal,
402
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
403
+ });
404
+ if (isFinal) {
405
+ this.stateMachine.handleTranscriptComplete();
406
+ }
407
+ }
408
+ /**
409
+ * Broadcasts an AI response to all clients.
410
+ */
411
+ broadcastResponse(text, commands) {
412
+ this.broadcast({
413
+ type: "response",
414
+ text,
415
+ commands,
416
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
417
+ });
418
+ }
419
+ /**
420
+ * Broadcasts an error to all clients.
421
+ */
422
+ broadcastError(message, code) {
423
+ this.stateMachine.handleError();
424
+ this.broadcast({
425
+ type: "error",
426
+ message,
427
+ code,
428
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
429
+ });
430
+ }
431
+ broadcast(message) {
432
+ const payload = JSON.stringify(message);
433
+ for (const client of this.clients) {
434
+ this.sendToClientRaw(client, payload);
435
+ }
436
+ }
437
+ sendToClient(client, message) {
438
+ this.sendToClientRaw(client, JSON.stringify(message));
439
+ }
440
+ sendToClientRaw(client, payload) {
441
+ if (client.socket.readyState !== WebSocket.OPEN) return;
442
+ try {
443
+ client.socket.send(payload);
444
+ } catch (error) {
445
+ this.logger.warn("Failed to send to client", { error: String(error) });
446
+ }
447
+ }
448
+ /**
449
+ * Shuts down the WebSocket server.
450
+ */
451
+ async shutdown() {
452
+ this.logger.info("Shutting down WebSocket server");
453
+ for (const client of this.clients) {
454
+ client.socket.close(1001, "Server shutting down");
455
+ }
456
+ this.clients.clear();
457
+ await new Promise((resolve, reject) => {
458
+ this.wss.close((err) => err ? reject(err) : resolve());
459
+ });
460
+ }
461
+ };
462
+
463
+ // src/voice-daemon/http-server.ts
464
+ import http from "http";
465
+ var VoiceDaemonHttpServer = class {
466
+ constructor(config, logger, wsServer) {
467
+ this.config = config;
468
+ this.logger = logger;
469
+ this.wsServer = wsServer;
470
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
471
+ }
472
+ /**
473
+ * Starts the HTTP server.
474
+ */
475
+ start() {
476
+ this.server.listen(this.config.httpPort, "127.0.0.1", () => {
477
+ this.logger.info("HTTP server listening", { port: this.config.httpPort });
478
+ });
479
+ }
480
+ handleRequest(req, res) {
481
+ const url = req.url || "/";
482
+ const method = req.method || "GET";
483
+ if (method === "GET" && url === "/health") {
484
+ res.writeHead(200, { "Content-Type": "application/json" });
485
+ res.end(JSON.stringify({ status: "ok", version: "0.1.0" }));
486
+ return;
487
+ }
488
+ if (method === "POST" && url === "/webhook/wakeword") {
489
+ this.handleWakewordWebhook(req, res);
490
+ return;
491
+ }
492
+ if (method === "GET" && url === "/status") {
493
+ res.writeHead(200, { "Content-Type": "application/json" });
494
+ res.end(
495
+ JSON.stringify({
496
+ status: "ok",
497
+ muted: this.config.muted,
498
+ approvalMode: this.config.approvalMode
499
+ })
500
+ );
501
+ return;
502
+ }
503
+ res.writeHead(404, { "Content-Type": "application/json" });
504
+ res.end(JSON.stringify({ error: "Not found" }));
505
+ }
506
+ handleWakewordWebhook(req, res) {
507
+ let body = "";
508
+ req.on("data", (chunk) => {
509
+ body += chunk.toString();
510
+ });
511
+ req.on("end", () => {
512
+ try {
513
+ const payload = JSON.parse(body);
514
+ if (payload.event === "wakeword" && payload.wakeword) {
515
+ this.logger.debug("Received wakeword webhook", { wakeword: payload.wakeword });
516
+ this.wsServer.handleWakeEvent(payload.wakeword);
517
+ res.writeHead(200, { "Content-Type": "application/json" });
518
+ res.end(JSON.stringify({ ok: true }));
519
+ } else {
520
+ res.writeHead(400, { "Content-Type": "application/json" });
521
+ res.end(JSON.stringify({ error: "Invalid payload" }));
522
+ }
523
+ } catch (error) {
524
+ this.logger.warn("Failed to parse wakeword webhook", { error: String(error) });
525
+ res.writeHead(400, { "Content-Type": "application/json" });
526
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
527
+ }
528
+ });
529
+ }
530
+ /**
531
+ * Shuts down the HTTP server.
532
+ */
533
+ async shutdown() {
534
+ await new Promise((resolve, reject) => {
535
+ this.server.close((err) => err ? reject(err) : resolve());
536
+ });
537
+ }
538
+ };
539
+
540
+ // src/voice-daemon/cli/terminal-ball.ts
541
+ var ANSI = {
542
+ reset: "\x1B[0m",
543
+ bold: "\x1B[1m",
544
+ dim: "\x1B[2m",
545
+ blink: "\x1B[5m",
546
+ // Colors
547
+ gray: "\x1B[90m",
548
+ blue: "\x1B[34m",
549
+ brightBlue: "\x1B[94m",
550
+ purple: "\x1B[35m",
551
+ green: "\x1B[32m",
552
+ brightGreen: "\x1B[92m",
553
+ red: "\x1B[31m",
554
+ brightRed: "\x1B[91m",
555
+ white: "\x1B[37m",
556
+ // Background
557
+ bgBlue: "\x1B[44m",
558
+ bgPurple: "\x1B[45m",
559
+ bgGreen: "\x1B[42m",
560
+ bgRed: "\x1B[41m",
561
+ bgGray: "\x1B[100m",
562
+ // Cursor
563
+ hide: "\x1B[?25l",
564
+ show: "\x1B[?25h",
565
+ clear: "\x1B[2J\x1B[H",
566
+ clearLine: "\x1B[2K",
567
+ moveTo: (row, col) => `\x1B[${row};${col}H`
568
+ };
569
+ var BALL_FRAMES = {
570
+ idle: [
571
+ " \u25CB ",
572
+ " (\xB7) ",
573
+ " \u25CB "
574
+ ],
575
+ listening: [
576
+ [
577
+ " .\u25CB. ",
578
+ " (( )) ",
579
+ " '\u25CB' "
580
+ ],
581
+ [
582
+ " . \u25CB . ",
583
+ " (( )) ",
584
+ " ' \u25CB ' "
585
+ ],
586
+ [
587
+ " . \u25CB . ",
588
+ "(( ))",
589
+ " ' \u25CB ' "
590
+ ]
591
+ ],
592
+ thinking: [
593
+ [
594
+ " \u25D0 ",
595
+ " \xB7\xB7\xB7 ",
596
+ " \u25D0 "
597
+ ],
598
+ [
599
+ " \u25D3 ",
600
+ " \xB7\xB7\xB7 ",
601
+ " \u25D3 "
602
+ ],
603
+ [
604
+ " \u25D1 ",
605
+ " \xB7\xB7\xB7 ",
606
+ " \u25D1 "
607
+ ],
608
+ [
609
+ " \u25D2 ",
610
+ " \xB7\xB7\xB7 ",
611
+ " \u25D2 "
612
+ ]
613
+ ],
614
+ speaking: [
615
+ [
616
+ " \u2581\u2583\u2585 ",
617
+ " (\u25C9\u25C9\u25C9) ",
618
+ " \u2594\u2594\u2594 "
619
+ ],
620
+ [
621
+ " \u2583\u2585\u2587 ",
622
+ " (\u25C9\u25C9\u25C9) ",
623
+ " \u2594\u2594\u2594 "
624
+ ],
625
+ [
626
+ " \u2585\u2587\u2585 ",
627
+ " (\u25C9\u25C9\u25C9) ",
628
+ " \u2594\u2594\u2594 "
629
+ ],
630
+ [
631
+ " \u2587\u2585\u2583 ",
632
+ " (\u25C9\u25C9\u25C9) ",
633
+ " \u2594\u2594\u2594 "
634
+ ]
635
+ ],
636
+ error: [
637
+ [
638
+ " \u26A0\uFE0F ",
639
+ " (\u2717\u2717\u2717) ",
640
+ " \u26A0\uFE0F "
641
+ ],
642
+ [
643
+ " \u26A0\uFE0F ",
644
+ " (\u2717\u2717\u2717) ",
645
+ " \u26A0\uFE0F "
646
+ ]
647
+ ]
648
+ };
649
+ var STATE_COLORS = {
650
+ idle: ANSI.gray,
651
+ listening: ANSI.brightBlue,
652
+ thinking: ANSI.purple,
653
+ speaking: ANSI.brightGreen,
654
+ error: ANSI.brightRed
655
+ };
656
+ var STATE_LABELS = {
657
+ idle: "IDLE - Say 'codmir' to activate",
658
+ listening: "LISTENING...",
659
+ thinking: "THINKING...",
660
+ speaking: "SPEAKING...",
661
+ error: "ERROR"
662
+ };
663
+ var TerminalBall = class {
664
+ constructor(options = {}) {
665
+ this.state = "idle";
666
+ this.frameIndex = 0;
667
+ this.intervalId = null;
668
+ this.lastTranscript = "";
669
+ this.lastResponse = "";
670
+ this.isRunning = false;
671
+ this.compact = options.compact ?? false;
672
+ }
673
+ /**
674
+ * Start the terminal ball animation.
675
+ */
676
+ start() {
677
+ if (this.isRunning) return;
678
+ this.isRunning = true;
679
+ process.stdout.write(ANSI.hide);
680
+ this.render();
681
+ this.intervalId = setInterval(() => {
682
+ this.frameIndex++;
683
+ this.render();
684
+ }, 200);
685
+ process.on("SIGINT", () => this.stop());
686
+ process.on("SIGTERM", () => this.stop());
687
+ }
688
+ /**
689
+ * Stop the terminal ball.
690
+ */
691
+ stop() {
692
+ if (!this.isRunning) return;
693
+ this.isRunning = false;
694
+ if (this.intervalId) {
695
+ clearInterval(this.intervalId);
696
+ this.intervalId = null;
697
+ }
698
+ process.stdout.write(ANSI.show);
699
+ process.stdout.write(ANSI.reset);
700
+ process.stdout.write("\n");
701
+ }
702
+ /**
703
+ * Update the assistant state.
704
+ */
705
+ setState(state) {
706
+ if (state !== this.state) {
707
+ this.state = state;
708
+ this.frameIndex = 0;
709
+ this.render();
710
+ }
711
+ }
712
+ /**
713
+ * Update the transcript display.
714
+ */
715
+ setTranscript(text) {
716
+ this.lastTranscript = text;
717
+ this.render();
718
+ }
719
+ /**
720
+ * Update the response display.
721
+ */
722
+ setResponse(text) {
723
+ this.lastResponse = text;
724
+ this.render();
725
+ }
726
+ /**
727
+ * Get the current state.
728
+ */
729
+ getState() {
730
+ return this.state;
731
+ }
732
+ render() {
733
+ if (!this.isRunning) return;
734
+ const color = STATE_COLORS[this.state];
735
+ const label = STATE_LABELS[this.state];
736
+ const frames = this.getFrames();
737
+ const frame = Array.isArray(frames[0]) ? frames[this.frameIndex % frames.length] : frames;
738
+ if (this.compact) {
739
+ this.renderCompact(color, label, frame);
740
+ } else {
741
+ this.renderFull(color, label, frame);
742
+ }
743
+ }
744
+ getFrames() {
745
+ return BALL_FRAMES[this.state];
746
+ }
747
+ renderCompact(color, label, frame) {
748
+ const line = frame.join(" ").replace(/\s+/g, " ").trim();
749
+ process.stdout.write(ANSI.clearLine);
750
+ process.stdout.write("\r");
751
+ process.stdout.write(`${color}${ANSI.bold}[codmir]${ANSI.reset} `);
752
+ process.stdout.write(`${color}${line}${ANSI.reset} `);
753
+ process.stdout.write(`${ANSI.dim}${label}${ANSI.reset}`);
754
+ }
755
+ renderFull(color, label, frame) {
756
+ const width = process.stdout.columns || 80;
757
+ const height = process.stdout.rows || 24;
758
+ const startRow = Math.floor(height / 2) - 2;
759
+ const startCol = Math.floor((width - 12) / 2);
760
+ process.stdout.write(ANSI.moveTo(startRow, 1));
761
+ const header = `${ANSI.bold}codmir Voice Assistant${ANSI.reset}`;
762
+ const headerPad = Math.floor((width - 22) / 2);
763
+ process.stdout.write(ANSI.clearLine);
764
+ process.stdout.write(" ".repeat(Math.max(0, headerPad)) + header + "\n");
765
+ for (const line of frame) {
766
+ process.stdout.write(ANSI.clearLine);
767
+ process.stdout.write(" ".repeat(Math.max(0, startCol)));
768
+ process.stdout.write(`${color}${line}${ANSI.reset}
769
+ `);
770
+ }
771
+ process.stdout.write(ANSI.clearLine);
772
+ const statusPad = Math.floor((width - label.length) / 2);
773
+ process.stdout.write(" ".repeat(Math.max(0, statusPad)));
774
+ process.stdout.write(`${color}${ANSI.bold}${label}${ANSI.reset}
775
+ `);
776
+ if (this.lastTranscript) {
777
+ process.stdout.write(ANSI.clearLine);
778
+ const tPad = Math.floor((width - this.lastTranscript.length - 8) / 2);
779
+ process.stdout.write(" ".repeat(Math.max(0, tPad)));
780
+ process.stdout.write(`${ANSI.dim}You: "${this.lastTranscript}"${ANSI.reset}
781
+ `);
782
+ } else {
783
+ process.stdout.write(ANSI.clearLine + "\n");
784
+ }
785
+ if (this.lastResponse) {
786
+ process.stdout.write(ANSI.clearLine);
787
+ const rPad = Math.floor((width - this.lastResponse.length - 10) / 2);
788
+ process.stdout.write(" ".repeat(Math.max(0, rPad)));
789
+ process.stdout.write(`${ANSI.brightGreen}codmir: "${this.lastResponse}"${ANSI.reset}
790
+ `);
791
+ }
792
+ }
793
+ };
794
+ var InlineBallIndicator = class {
795
+ constructor() {
796
+ this.state = "idle";
797
+ }
798
+ setState(state) {
799
+ this.state = state;
800
+ }
801
+ getIndicator() {
802
+ const indicators = {
803
+ idle: "\u25CB",
804
+ listening: "\u25C9",
805
+ thinking: "\u25D0",
806
+ speaking: "\u25C8",
807
+ error: "\u2717"
808
+ };
809
+ const colors = {
810
+ idle: ANSI.gray,
811
+ listening: ANSI.brightBlue,
812
+ thinking: ANSI.purple,
813
+ speaking: ANSI.brightGreen,
814
+ error: ANSI.brightRed
815
+ };
816
+ return `${colors[this.state]}${indicators[this.state]}${ANSI.reset}`;
817
+ }
818
+ getStatusLine() {
819
+ const labels = {
820
+ idle: "idle",
821
+ listening: "listening",
822
+ thinking: "thinking",
823
+ speaking: "speaking",
824
+ error: "error"
825
+ };
826
+ return `[codmir ${this.getIndicator()}] ${labels[this.state]}`;
827
+ }
828
+ };
829
+
830
+ // src/voice-daemon/cli/renderer.ts
831
+ function detectDisplayMode() {
832
+ const hasDisplay = !!process.env.DISPLAY || !!process.env.WAYLAND_DISPLAY;
833
+ const isTTY = process.stdout.isTTY;
834
+ const isCI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
835
+ const isSSH = !!process.env.SSH_CLIENT || !!process.env.SSH_TTY;
836
+ const termSupportsColor = process.env.TERM !== "dumb" && process.env.COLORTERM !== void 0;
837
+ if (hasDisplay && !isSSH && !isCI) {
838
+ return "gui";
839
+ }
840
+ if (isTTY && !isCI) {
841
+ const cols = process.stdout.columns || 80;
842
+ const rows = process.stdout.rows || 24;
843
+ if (cols >= 40 && rows >= 10) {
844
+ return "terminal";
845
+ }
846
+ return "compact";
847
+ }
848
+ if (termSupportsColor) {
849
+ return "inline";
850
+ }
851
+ return "none";
852
+ }
853
+ var CLIRenderer = class {
854
+ constructor(options = {}) {
855
+ this.terminalBall = null;
856
+ this.inlineIndicator = null;
857
+ this.state = "idle";
858
+ this.guiProcess = null;
859
+ this.mode = options.forceMode ? options.mode || "terminal" : detectDisplayMode();
860
+ }
861
+ /**
862
+ * Get the current display mode.
863
+ */
864
+ getMode() {
865
+ return this.mode;
866
+ }
867
+ /**
868
+ * Start the renderer.
869
+ */
870
+ async start() {
871
+ switch (this.mode) {
872
+ case "gui":
873
+ await this.startGUI();
874
+ break;
875
+ case "terminal":
876
+ this.terminalBall = new TerminalBall({ compact: false });
877
+ this.terminalBall.start();
878
+ break;
879
+ case "compact":
880
+ this.terminalBall = new TerminalBall({ compact: true });
881
+ this.terminalBall.start();
882
+ break;
883
+ case "inline":
884
+ this.inlineIndicator = new InlineBallIndicator();
885
+ console.log("[codmir] Voice assistant started (inline mode)");
886
+ break;
887
+ case "none":
888
+ console.log("[codmir] Voice assistant started (no display)");
889
+ break;
890
+ }
891
+ }
892
+ /**
893
+ * Stop the renderer.
894
+ */
895
+ stop() {
896
+ if (this.terminalBall) {
897
+ this.terminalBall.stop();
898
+ this.terminalBall = null;
899
+ }
900
+ if (this.guiProcess) {
901
+ this.guiProcess.kill();
902
+ this.guiProcess = null;
903
+ }
904
+ }
905
+ /**
906
+ * Update the assistant state.
907
+ */
908
+ setState(state) {
909
+ this.state = state;
910
+ if (this.terminalBall) {
911
+ this.terminalBall.setState(state);
912
+ }
913
+ if (this.inlineIndicator) {
914
+ this.inlineIndicator.setState(state);
915
+ console.log(this.inlineIndicator.getStatusLine());
916
+ }
917
+ }
918
+ /**
919
+ * Update transcript display.
920
+ */
921
+ setTranscript(text) {
922
+ if (this.terminalBall) {
923
+ this.terminalBall.setTranscript(text);
924
+ }
925
+ if (this.mode === "inline" || this.mode === "none") {
926
+ console.log(`[codmir] Transcript: "${text}"`);
927
+ }
928
+ }
929
+ /**
930
+ * Update response display.
931
+ */
932
+ setResponse(text) {
933
+ if (this.terminalBall) {
934
+ this.terminalBall.setResponse(text);
935
+ }
936
+ if (this.mode === "inline" || this.mode === "none") {
937
+ console.log(`[codmir] Response: "${text}"`);
938
+ }
939
+ }
940
+ async startGUI() {
941
+ const { spawn } = await import("child_process");
942
+ const path = await import("path");
943
+ const overlayScript = path.join(__dirname, "..", "..", "overlay", "ball.py");
944
+ try {
945
+ this.guiProcess = spawn("python3", [overlayScript], {
946
+ detached: true,
947
+ stdio: "ignore",
948
+ env: { ...process.env }
949
+ });
950
+ this.guiProcess.unref();
951
+ } catch (error) {
952
+ console.warn("[codmir] Failed to start GUI overlay, falling back to terminal mode");
953
+ this.terminalBall = new TerminalBall({ compact: false });
954
+ this.terminalBall.start();
955
+ }
956
+ }
957
+ };
958
+ function createRenderer(options) {
959
+ return new CLIRenderer(options);
960
+ }
961
+
962
+ // src/voice-daemon/index.ts
963
+ var TEST_STATES = ["idle", "listening", "thinking", "speaking", "idle"];
964
+ var TEST_TRANSCRIPTS = ["", "open firefox", "", "", ""];
965
+ var TEST_RESPONSES = ["", "", "", "Opening Firefox for you", ""];
966
+ async function main() {
967
+ const logLevel = normalizeLogLevel(process.env.CODMIR_VOICE_LOG_LEVEL);
968
+ const logger = new Logger(logLevel);
969
+ const isTestMode = process.env.CODMIR_TEST_MODE === "1";
970
+ const displayMode = process.env.CODMIR_DISPLAY_MODE;
971
+ logger.info("Starting codmir voice daemon...");
972
+ const config = loadConfig(logger);
973
+ logger.info("Configuration loaded", {
974
+ port: config.port,
975
+ httpPort: config.httpPort,
976
+ approvalMode: config.approvalMode,
977
+ displayMode: displayMode || "auto",
978
+ testMode: isTestMode
979
+ });
980
+ const stateMachine = new AssistantStateMachine(logger);
981
+ const executor = new CommandExecutor(config, logger);
982
+ const wsServer = new VoiceDaemonWebSocketServer(config, logger, stateMachine, executor);
983
+ const httpServer = new VoiceDaemonHttpServer(config, logger, wsServer);
984
+ const renderer = createRenderer({
985
+ mode: displayMode,
986
+ forceMode: !!displayMode
987
+ });
988
+ stateMachine.onStateChange((state) => {
989
+ renderer.setState(state);
990
+ });
991
+ httpServer.start();
992
+ await renderer.start();
993
+ if (isTestMode) {
994
+ logger.info("Running in test mode - cycling through states");
995
+ let stateIndex = 0;
996
+ const cycleStates = () => {
997
+ const state = TEST_STATES[stateIndex];
998
+ const transcript = TEST_TRANSCRIPTS[stateIndex];
999
+ const response = TEST_RESPONSES[stateIndex];
1000
+ stateMachine.setState(state);
1001
+ if (transcript) renderer.setTranscript(transcript);
1002
+ if (response) renderer.setResponse(response);
1003
+ stateIndex = (stateIndex + 1) % TEST_STATES.length;
1004
+ };
1005
+ cycleStates();
1006
+ setInterval(cycleStates, 2500);
1007
+ }
1008
+ const shutdown = async (signal) => {
1009
+ logger.info("Received shutdown signal", { signal });
1010
+ try {
1011
+ renderer.stop();
1012
+ await httpServer.shutdown();
1013
+ await wsServer.shutdown();
1014
+ } catch (error) {
1015
+ logger.error("Error during shutdown", { error: String(error) });
1016
+ } finally {
1017
+ process.exit(0);
1018
+ }
1019
+ };
1020
+ process.on("SIGINT", () => void shutdown("SIGINT"));
1021
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
1022
+ logger.info("codmir voice daemon ready", {
1023
+ wsPort: config.port,
1024
+ httpPort: config.httpPort,
1025
+ displayMode: renderer.getMode()
1026
+ });
1027
+ }
1028
+ if (__require.main === module) {
1029
+ void main().catch((error) => {
1030
+ console.error("Fatal error starting codmir voice daemon:", error);
1031
+ process.exit(1);
1032
+ });
1033
+ }
1034
+ export {
1035
+ AssistantStateMachine,
1036
+ CLIRenderer,
1037
+ CommandExecutor,
1038
+ InlineBallIndicator,
1039
+ Logger,
1040
+ TerminalBall,
1041
+ VoiceDaemonHttpServer,
1042
+ VoiceDaemonWebSocketServer,
1043
+ createRenderer,
1044
+ loadConfig,
1045
+ normalizeLogLevel
1046
+ };