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