opencharm 0.0.0 → 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.
package/dist/main.mjs ADDED
@@ -0,0 +1,4157 @@
1
+ #!/usr/bin/env node
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { dirname, extname, isAbsolute, join, normalize, resolve, sep } from "node:path";
5
+ import { z } from "zod";
6
+ import { createConnection, createServer } from "node:net";
7
+ import { createInterface } from "node:readline";
8
+ import { execFile, spawn, spawnSync } from "node:child_process";
9
+ import { createHash, randomBytes, randomInt, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
10
+ import { createServer as createServer$1 } from "node:http";
11
+ import { WebSocketServer } from "ws";
12
+ import { Readable, Writable } from "node:stream";
13
+ import * as acp from "@agentclientprotocol/sdk";
14
+ import OpusScript from "opusscript";
15
+ import { fileURLToPath } from "node:url";
16
+ //#endregion
17
+ //#region ../design/src/faces-data.ts
18
+ const facesData = {
19
+ $comment: "Generated from packages/design/src/charm-face.js by packages/design/scripts/export-faces.ts. Edit the JS, then run npm run design:export.",
20
+ signal: "#FF5A1F",
21
+ colours: [
22
+ {
23
+ "id": "white",
24
+ "name": "White",
25
+ "c": "#FFFFFF",
26
+ "g": "#F4F3EE",
27
+ "key": "#1E1F22"
28
+ },
29
+ {
30
+ "id": "cobalt",
31
+ "name": "Cobalt",
32
+ "c": "#4574FF",
33
+ "g": "#9DB6FF",
34
+ "key": "#FFD166"
35
+ },
36
+ {
37
+ "id": "lime",
38
+ "name": "Lime",
39
+ "c": "#BDEB4E",
40
+ "g": "#D6F78A",
41
+ "key": "#1E1F22"
42
+ },
43
+ {
44
+ "id": "lilac",
45
+ "name": "Lilac",
46
+ "c": "#B39BFA",
47
+ "g": "#D2C4FF",
48
+ "key": "#1E1F22"
49
+ },
50
+ {
51
+ "id": "sun",
52
+ "name": "Sun",
53
+ "c": "#FFCD5C",
54
+ "g": "#FFDF93",
55
+ "key": "#1E1F22"
56
+ },
57
+ {
58
+ "id": "coal",
59
+ "name": "Coal",
60
+ "c": "#2B2C30",
61
+ "g": "#F4F3EE",
62
+ "key": "#FF5A1F"
63
+ }
64
+ ],
65
+ faces: [
66
+ {
67
+ "id": "neutral",
68
+ "text": "o o",
69
+ "t": "Neutral",
70
+ "L": "o",
71
+ "R": "o"
72
+ },
73
+ {
74
+ "id": "happy",
75
+ "text": "^ ^",
76
+ "t": "Happy",
77
+ "L": "^",
78
+ "R": "^"
79
+ },
80
+ {
81
+ "id": "joy",
82
+ "text": "^ v ^",
83
+ "t": "Joy",
84
+ "L": "^",
85
+ "R": "^",
86
+ "M": "v",
87
+ "sl": 1.06,
88
+ "sr": 1.06
89
+ },
90
+ {
91
+ "id": "cute",
92
+ "text": "^ w ^",
93
+ "t": "Cute",
94
+ "L": "^",
95
+ "R": "^",
96
+ "M": "w"
97
+ },
98
+ {
99
+ "id": "wink",
100
+ "text": "^ −",
101
+ "t": "Wink",
102
+ "L": "^",
103
+ "R": "−",
104
+ "tilt": -5
105
+ },
106
+ {
107
+ "id": "loved",
108
+ "text": "♥︎ ♥︎",
109
+ "t": "Loved",
110
+ "L": "♥︎",
111
+ "R": "♥︎",
112
+ "sl": .9,
113
+ "sr": .9
114
+ },
115
+ {
116
+ "id": "listening",
117
+ "text": "O O",
118
+ "t": "Listening",
119
+ "L": "O",
120
+ "R": "O",
121
+ "gy": -.03
122
+ },
123
+ {
124
+ "id": "thinking",
125
+ "text": "o _ o",
126
+ "t": "Thinking",
127
+ "L": "o",
128
+ "R": "o",
129
+ "M": "_",
130
+ "gx": .05,
131
+ "gy": -.04,
132
+ "tilt": 5,
133
+ "fx": "cursor"
134
+ },
135
+ {
136
+ "id": "focused",
137
+ "text": "− −",
138
+ "t": "Focused",
139
+ "L": "−",
140
+ "R": "−",
141
+ "rl": 9,
142
+ "rr": -9
143
+ },
144
+ {
145
+ "id": "curious",
146
+ "text": "o •",
147
+ "t": "Curious",
148
+ "L": "o",
149
+ "R": "•",
150
+ "sl": 1.15,
151
+ "tilt": -8,
152
+ "gx": .02
153
+ },
154
+ {
155
+ "id": "confused",
156
+ "text": "o O",
157
+ "t": "Confused",
158
+ "L": "o",
159
+ "R": "O",
160
+ "tilt": 7
161
+ },
162
+ {
163
+ "id": "surprised",
164
+ "text": "O o O",
165
+ "t": "Surprised",
166
+ "L": "O",
167
+ "R": "O",
168
+ "M": "o"
169
+ },
170
+ {
171
+ "id": "doubtful",
172
+ "text": "¬ ¬",
173
+ "t": "Doubtful",
174
+ "L": "¬",
175
+ "R": "¬",
176
+ "gx": .06
177
+ },
178
+ {
179
+ "id": "unamused",
180
+ "text": "− _ −",
181
+ "t": "Unamused",
182
+ "L": "−",
183
+ "R": "−",
184
+ "M": "_"
185
+ },
186
+ {
187
+ "id": "strain",
188
+ "text": "> <",
189
+ "t": "Strain",
190
+ "L": ">",
191
+ "R": "<"
192
+ },
193
+ {
194
+ "id": "sad",
195
+ "text": "T T",
196
+ "t": "Sad",
197
+ "L": "T",
198
+ "R": "T",
199
+ "gy": .02
200
+ },
201
+ {
202
+ "id": "oops",
203
+ "text": "x x",
204
+ "t": "Oops",
205
+ "L": "x",
206
+ "R": "x"
207
+ },
208
+ {
209
+ "id": "dizzy",
210
+ "text": "@ @",
211
+ "t": "Dizzy",
212
+ "L": "@",
213
+ "R": "@",
214
+ "tilt": 10,
215
+ "fx": "spin"
216
+ },
217
+ {
218
+ "id": "sleepy",
219
+ "text": "− −",
220
+ "t": "Sleepy",
221
+ "L": "−",
222
+ "R": "−",
223
+ "dy": .04,
224
+ "fx": "z"
225
+ },
226
+ {
227
+ "id": "learned",
228
+ "text": "* v *",
229
+ "t": "Learned",
230
+ "L": "*",
231
+ "R": "*",
232
+ "M": "v",
233
+ "sl": 1.1,
234
+ "sr": 1.1
235
+ },
236
+ {
237
+ "id": "money",
238
+ "text": "$ $",
239
+ "t": "Money",
240
+ "L": "$",
241
+ "R": "$"
242
+ },
243
+ {
244
+ "id": "ask",
245
+ "text": "o ? o",
246
+ "t": "Needs you",
247
+ "L": "o",
248
+ "R": "o",
249
+ "M": "?",
250
+ "fx": "ask"
251
+ }
252
+ ],
253
+ states: [
254
+ {
255
+ "id": "greeting",
256
+ "face": "happy",
257
+ "name": "Hello",
258
+ "when": "You pick it up or sit down.",
259
+ "trigger": "IMU pick-up / first touch of the day"
260
+ },
261
+ {
262
+ "id": "listening",
263
+ "face": "listening",
264
+ "name": "Listening",
265
+ "when": "You hold the key and talk.",
266
+ "trigger": "Key held (on device)"
267
+ },
268
+ {
269
+ "id": "thinking",
270
+ "face": "thinking",
271
+ "name": "Thinking",
272
+ "when": "The agent is working on an answer.",
273
+ "trigger": "Agent turn started"
274
+ },
275
+ {
276
+ "id": "working",
277
+ "face": "focused",
278
+ "name": "Working",
279
+ "when": "A tool or a long task is running.",
280
+ "trigger": "Tool call started"
281
+ },
282
+ {
283
+ "id": "needs_you",
284
+ "face": "ask",
285
+ "name": "Needs you",
286
+ "when": "A question or an approval. Orange ring, one sound.",
287
+ "trigger": "Approval request / clarifying question"
288
+ },
289
+ {
290
+ "id": "done",
291
+ "face": "joy",
292
+ "name": "Done",
293
+ "when": "A task finished. One line, then back to the face.",
294
+ "trigger": "Turn complete / cron job done"
295
+ },
296
+ {
297
+ "id": "stuck",
298
+ "face": "strain",
299
+ "name": "Stuck",
300
+ "when": "Retries are piling up.",
301
+ "trigger": "3 tool errors in a row"
302
+ },
303
+ {
304
+ "id": "failed",
305
+ "face": "oops",
306
+ "name": "Failed",
307
+ "when": "Something broke. It says what, in one line.",
308
+ "trigger": "Run failed"
309
+ },
310
+ {
311
+ "id": "learned",
312
+ "face": "learned",
313
+ "name": "Learned",
314
+ "when": "It made or improved a skill.",
315
+ "trigger": "Hermes: skill created or updated"
316
+ },
317
+ {
318
+ "id": "noted",
319
+ "face": "wink",
320
+ "name": "Noted",
321
+ "when": "It saved something about you.",
322
+ "trigger": "Memory write (e.g. USER.md / MEMORY.md)"
323
+ },
324
+ {
325
+ "id": "cost",
326
+ "face": "money",
327
+ "name": "Cost",
328
+ "when": "Spend passed a limit you set.",
329
+ "trigger": "Budget threshold"
330
+ },
331
+ {
332
+ "id": "asleep",
333
+ "face": "sleepy",
334
+ "name": "Asleep",
335
+ "when": "Face-down or quiet hours. The agent keeps running.",
336
+ "trigger": "IMU face-down / schedule"
337
+ },
338
+ {
339
+ "id": "idle",
340
+ "face": "neutral",
341
+ "name": "Idle",
342
+ "when": "Nothing going on. Slow blinks and glances.",
343
+ "trigger": "No events"
344
+ }
345
+ ],
346
+ fields: {
347
+ "L": "left eye glyph",
348
+ "R": "right eye glyph",
349
+ "M": "mouth glyph (optional)",
350
+ "gx": "gaze x, fraction of screen",
351
+ "gy": "gaze y, fraction of screen",
352
+ "dy": "move eyes down, fraction",
353
+ "dl": "move left eye down, fraction",
354
+ "dr": "move right eye down, fraction",
355
+ "rl": "rotate left eye, degrees",
356
+ "rr": "rotate right eye, degrees",
357
+ "sl": "scale left eye",
358
+ "sr": "scale right eye",
359
+ "tilt": "rotate whole face, degrees",
360
+ "fx": "cursor | spin | z | ask"
361
+ }
362
+ };
363
+ //#endregion
364
+ //#region ../charmd/src/agent/acp-agents.ts
365
+ const ACP_AGENTS = {
366
+ claude: {
367
+ label: "Claude Code",
368
+ command: [
369
+ "npx",
370
+ "-y",
371
+ "@agentclientprotocol/claude-agent-acp@0.84.0"
372
+ ],
373
+ sessionMeta: { claudeCode: { options: {
374
+ settingSources: ["project", "local"],
375
+ allowedTools: ["mcp__charm"]
376
+ } } },
377
+ mode: "acceptEdits",
378
+ env: { ENABLE_CLAUDEAI_MCP_SERVERS: "0" }
379
+ },
380
+ codex: {
381
+ label: "Codex",
382
+ command: [
383
+ "npx",
384
+ "-y",
385
+ "@agentclientprotocol/codex-acp@2.1.0"
386
+ ]
387
+ },
388
+ gemini: {
389
+ label: "Gemini CLI",
390
+ command: ["gemini", "--acp"]
391
+ },
392
+ goose: {
393
+ label: "goose",
394
+ command: ["goose", "acp"]
395
+ },
396
+ hermes: {
397
+ label: "Hermes",
398
+ command: ["hermes", "acp"]
399
+ },
400
+ openclaw: {
401
+ label: "OpenClaw",
402
+ command: ["openclaw", "acp"]
403
+ }
404
+ };
405
+ const ACP_AGENT_NAMES = Object.keys(ACP_AGENTS);
406
+ //#endregion
407
+ //#region ../protocol/src/constants.ts
408
+ const AUDIO_DOWN = {
409
+ format: "opus",
410
+ sample_rate: 24e3,
411
+ channels: 1,
412
+ frame_duration: 60
413
+ };
414
+ const LIMITS = {
415
+ maxJsonBytes: 16384,
416
+ maxAudioFrameBytes: 4096,
417
+ maxTextChars: 2e3
418
+ };
419
+ const TIMEOUTS = {
420
+ pairCodeSeconds: 300,
421
+ unpairedIdleSeconds: 120,
422
+ turnSeconds: 60
423
+ };
424
+ const FACE_STATES = facesData.states.map((state) => state.id);
425
+ const SIGNAL_COLOUR = "#FF5A1F";
426
+ //#endregion
427
+ //#region ../protocol/src/text.ts
428
+ const encoder$1 = new TextEncoder();
429
+ const ELLIPSIS = "…";
430
+ function utf8Length(text) {
431
+ return encoder$1.encode(text).length;
432
+ }
433
+ function fitUtf8(text, maxBytes) {
434
+ if (utf8Length(text) <= maxBytes) return text;
435
+ const budget = maxBytes - utf8Length(ELLIPSIS);
436
+ let out = "";
437
+ let used = 0;
438
+ for (const char of text) {
439
+ const size = utf8Length(char);
440
+ if (used + size > budget) break;
441
+ out += char;
442
+ used += size;
443
+ }
444
+ return out + ELLIPSIS;
445
+ }
446
+ //#endregion
447
+ //#region ../protocol/src/messages.ts
448
+ const sessionId = z.string().min(1).max(128).optional();
449
+ const shownText = (min, maxBytes) => z.string().min(min).refine((text) => utf8Length(text) <= maxBytes, { message: `at most ${maxBytes} bytes` });
450
+ const askId = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
451
+ const askLabel = shownText(1, 12);
452
+ const audioParams = z.object({
453
+ format: z.literal("opus"),
454
+ sample_rate: z.number().int().positive(),
455
+ channels: z.number().int().min(1).max(2),
456
+ frame_duration: z.number().int().positive()
457
+ });
458
+ const clientHello = z.object({
459
+ type: z.literal("hello"),
460
+ version: z.number().int().positive(),
461
+ features: z.record(z.string(), z.boolean()).optional(),
462
+ transport: z.literal("websocket"),
463
+ audio_params: audioParams
464
+ });
465
+ const listen = z.object({
466
+ session_id: sessionId,
467
+ type: z.literal("listen"),
468
+ state: z.enum([
469
+ "start",
470
+ "stop",
471
+ "detect"
472
+ ]),
473
+ mode: z.enum([
474
+ "auto",
475
+ "manual",
476
+ "realtime"
477
+ ]).optional(),
478
+ text: z.string().max(2e3).optional()
479
+ });
480
+ const abort = z.object({
481
+ session_id: sessionId,
482
+ type: z.literal("abort"),
483
+ reason: z.string().max(64).optional()
484
+ });
485
+ const unlock = z.object({
486
+ type: z.literal("charm"),
487
+ op: z.literal("unlock"),
488
+ pin: z.string().regex(/^\d{4,12}$/)
489
+ });
490
+ const answer = z.object({
491
+ type: z.literal("charm"),
492
+ op: z.literal("answer"),
493
+ id: askId,
494
+ yes: z.boolean()
495
+ });
496
+ const serverHello = z.object({
497
+ type: z.literal("hello"),
498
+ transport: z.literal("websocket"),
499
+ session_id: z.string().min(1).max(128),
500
+ audio_params: audioParams
501
+ });
502
+ const stt = z.object({
503
+ session_id: sessionId,
504
+ type: z.literal("stt"),
505
+ text: z.string().max(2e3)
506
+ });
507
+ const tts = z.object({
508
+ session_id: sessionId,
509
+ type: z.literal("tts"),
510
+ state: z.enum([
511
+ "start",
512
+ "sentence_start",
513
+ "stop"
514
+ ]),
515
+ text: z.string().max(2e3).optional()
516
+ });
517
+ const llm = z.object({
518
+ session_id: sessionId,
519
+ type: z.literal("llm"),
520
+ emotion: z.string().max(32),
521
+ text: z.string().max(32).optional()
522
+ });
523
+ const pairCode = z.object({
524
+ type: z.literal("charm"),
525
+ op: z.literal("pair_code"),
526
+ code: z.string().regex(/^\d{6}$/),
527
+ expires_in: z.number().int().positive()
528
+ });
529
+ const paired = z.object({
530
+ type: z.literal("charm"),
531
+ op: z.literal("paired"),
532
+ token: z.string().regex(/^[A-Za-z0-9_-]{32,128}$/)
533
+ });
534
+ const unlocked = z.object({
535
+ type: z.literal("charm"),
536
+ op: z.literal("unlocked")
537
+ });
538
+ const locked = z.object({
539
+ type: z.literal("charm"),
540
+ op: z.literal("locked"),
541
+ reason: z.enum([
542
+ "boot",
543
+ "wrong_pin",
544
+ "remote",
545
+ "blocked"
546
+ ]),
547
+ tries_left: z.number().int().min(0).optional()
548
+ });
549
+ const revoked = z.object({
550
+ type: z.literal("charm"),
551
+ op: z.literal("revoked")
552
+ });
553
+ const face = z.object({
554
+ type: z.literal("charm"),
555
+ op: z.literal("face"),
556
+ state: z.enum(FACE_STATES),
557
+ text: shownText(0, 200).optional()
558
+ });
559
+ const look$1 = z.object({
560
+ type: z.literal("charm"),
561
+ op: z.literal("look"),
562
+ name: shownText(1, 24).refine((name) => [...name].length <= 12, { message: "at most 12 characters" }),
563
+ glyph: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "a #RRGGBB colour").refine((hex) => hex.toUpperCase() !== SIGNAL_COLOUR, { message: "orange is reserved for 'it needs you'" }),
564
+ greeting: shownText(0, 40),
565
+ sleep_ms: z.number().int().min(0).max(864e5),
566
+ motion: z.enum(["full", "calm"])
567
+ });
568
+ const ask = z.object({
569
+ type: z.literal("charm"),
570
+ op: z.literal("ask"),
571
+ id: askId,
572
+ text: shownText(1, 200),
573
+ yes: askLabel.optional(),
574
+ no: askLabel.optional()
575
+ });
576
+ const askEnd = z.object({
577
+ type: z.literal("charm"),
578
+ op: z.literal("ask_end"),
579
+ id: askId
580
+ });
581
+ const CLIENT_SCHEMAS = {
582
+ hello: clientHello,
583
+ listen,
584
+ abort,
585
+ "charm:unlock": unlock,
586
+ "charm:answer": answer
587
+ };
588
+ const SERVER_SCHEMAS = {
589
+ hello: serverHello,
590
+ stt,
591
+ tts,
592
+ llm,
593
+ "charm:pair_code": pairCode,
594
+ "charm:paired": paired,
595
+ "charm:unlocked": unlocked,
596
+ "charm:locked": locked,
597
+ "charm:revoked": revoked,
598
+ "charm:face": face,
599
+ "charm:ask": ask,
600
+ "charm:ask_end": askEnd,
601
+ "charm:look": look$1
602
+ };
603
+ Object.keys(CLIENT_SCHEMAS);
604
+ Object.keys(SERVER_SCHEMAS);
605
+ function messageKind(message) {
606
+ return message.type === "charm" ? `charm:${message.op ?? ""}` : message.type;
607
+ }
608
+ //#endregion
609
+ //#region ../charmd/src/look.ts
610
+ const FALLBACK_NAME = "Charm";
611
+ const MAX_NAME_CHARS = 12;
612
+ const MAX_NAME_BYTES = 24;
613
+ const MAX_GREETING_BYTES = 40;
614
+ const COLOUR_IDS = facesData.colours.map((colour) => colour.id);
615
+ const lookName = z.string().min(1, "the name can't be empty").refine((name) => [...name].length <= MAX_NAME_CHARS && utf8Length(name) <= MAX_NAME_BYTES, { message: `at most ${MAX_NAME_CHARS} characters` });
616
+ const lookFields = {
617
+ name: lookName.optional(),
618
+ colour: z.enum(COLOUR_IDS, { error: `the colour is one of ${COLOUR_IDS.join(", ")}` }).optional(),
619
+ greeting: z.string().refine((text) => utf8Length(text) <= MAX_GREETING_BYTES, { message: `at most ${MAX_GREETING_BYTES} bytes` }).optional(),
620
+ sleepAfterMinutes: z.number().int().min(0).max(1440).optional(),
621
+ motion: z.enum(["full", "calm"]).optional()
622
+ };
623
+ const lookUpdateSchema = z.object(lookFields).strict();
624
+ function issues(error) {
625
+ return error.issues.map((issue) => `${issue.path.join(".") || "look"}: ${issue.message}`).join("; ");
626
+ }
627
+ function glyphOf(colourId) {
628
+ const colour = facesData.colours.find((c) => c.id === colourId);
629
+ if (!colour) throw new Error(`Unknown colour "${colourId}"`);
630
+ return colour.g;
631
+ }
632
+ function resolve$1(settings, defaultName) {
633
+ const name = settings.name ?? defaultName;
634
+ return {
635
+ name,
636
+ colour: settings.colour,
637
+ glyph: glyphOf(settings.colour),
638
+ greeting: settings.greeting ?? `Hi! I'm ${name}.`,
639
+ sleepAfterMinutes: settings.sleepAfterMinutes,
640
+ motion: settings.motion
641
+ };
642
+ }
643
+ function toMessage(look) {
644
+ const message = {
645
+ type: "charm",
646
+ op: "look",
647
+ name: look.name,
648
+ glyph: look.glyph,
649
+ greeting: look.greeting,
650
+ sleep_ms: look.sleepAfterMinutes * 6e4,
651
+ motion: look.motion
652
+ };
653
+ const checked = SERVER_SCHEMAS["charm:look"].safeParse(message);
654
+ if (!checked.success) throw new Error(`Invalid look: ${issues(checked.error)}`);
655
+ return message;
656
+ }
657
+ function nameFromAgentsMd(markdown) {
658
+ const heading = /^# +(.+)$/m.exec(markdown)?.[1]?.trim();
659
+ if (!heading) return void 0;
660
+ const name = [...heading].slice(0, MAX_NAME_CHARS).join("").trimEnd();
661
+ return lookName.safeParse(name).success ? name : void 0;
662
+ }
663
+ function readAgentName(cwd) {
664
+ const path = cwd ? join(cwd, "AGENTS.md") : void 0;
665
+ if (!path || !existsSync(path)) return FALLBACK_NAME;
666
+ return nameFromAgentsMd(readFileSync(path, "utf8")) ?? FALLBACK_NAME;
667
+ }
668
+ var CharmLook = class {
669
+ #settings;
670
+ #defaultName;
671
+ constructor(settings, defaultName) {
672
+ this.#settings = settings;
673
+ this.#defaultName = defaultName;
674
+ this.message();
675
+ }
676
+ current() {
677
+ return resolve$1(this.#settings, this.#defaultName);
678
+ }
679
+ message() {
680
+ return toMessage(this.current());
681
+ }
682
+ apply(update) {
683
+ const parsed = lookUpdateSchema.safeParse(update);
684
+ if (!parsed.success) throw new Error(issues(parsed.error));
685
+ const next = { ...this.#settings };
686
+ for (const [key, value] of Object.entries(parsed.data)) if (value !== void 0) Object.assign(next, { [key]: value });
687
+ const look = resolve$1(next, this.#defaultName);
688
+ toMessage(look);
689
+ this.#settings = next;
690
+ return look;
691
+ }
692
+ };
693
+ //#endregion
694
+ //#region ../charmd/src/config/config.ts
695
+ const LOOPBACK = /* @__PURE__ */ new Set([
696
+ "127.0.0.1",
697
+ "::1",
698
+ "localhost"
699
+ ]);
700
+ const DEFAULT_SEARCH_PATHS = [join(process.cwd(), "opencharm.json"), "/etc/opencharm/charmd.json"];
701
+ const secret = {
702
+ apiKey: z.string().min(1).optional(),
703
+ apiKeyEnv: z.string().min(1).optional()
704
+ };
705
+ const voiceSchema = z.discriminatedUnion("provider", [
706
+ z.object({
707
+ provider: z.literal("fake"),
708
+ transcript: z.string().optional()
709
+ }).strict(),
710
+ z.object({
711
+ provider: z.literal("local"),
712
+ whisperModel: z.string().optional(),
713
+ sayVoice: z.string().optional()
714
+ }).strict(),
715
+ z.object({
716
+ provider: z.literal("openai"),
717
+ ...secret,
718
+ baseUrl: z.url().optional(),
719
+ sttModel: z.string().optional(),
720
+ ttsModel: z.string().optional(),
721
+ voice: z.string().optional()
722
+ }).strict()
723
+ ]);
724
+ const agentSchema = z.discriminatedUnion("adapter", [
725
+ z.object({ adapter: z.literal("fake") }).strict(),
726
+ z.object({
727
+ adapter: z.literal("openai-compatible"),
728
+ baseUrl: z.url(),
729
+ model: z.string().min(1),
730
+ ...secret
731
+ }).strict(),
732
+ z.object({
733
+ adapter: z.literal("openclaw"),
734
+ baseUrl: z.url(),
735
+ agentId: z.string().min(1).default("main"),
736
+ ...secret
737
+ }).strict(),
738
+ z.object({
739
+ adapter: z.literal("hermes"),
740
+ baseUrl: z.url().default("http://127.0.0.1:8642/v1"),
741
+ model: z.string().min(1).default("hermes-agent"),
742
+ ...secret
743
+ }).strict(),
744
+ z.object({
745
+ adapter: z.literal("acp"),
746
+ agent: z.enum(ACP_AGENT_NAMES).optional(),
747
+ command: z.array(z.string().min(1)).min(1).optional(),
748
+ cwd: z.string().min(1).default("."),
749
+ projects: z.array(z.string().min(1)).optional(),
750
+ mode: z.string().min(1).optional(),
751
+ charmTools: z.boolean().default(true)
752
+ }).strict()
753
+ ]);
754
+ const charmSchema = z.object({
755
+ ...lookFields,
756
+ colour: lookFields.colour.unwrap().default("white"),
757
+ sleepAfterMinutes: lookFields.sleepAfterMinutes.unwrap().default(4),
758
+ motion: lookFields.motion.unwrap().default("full"),
759
+ agentCanChangeLook: z.boolean().default(true)
760
+ }).strict();
761
+ const schema = z.object({
762
+ listen: z.object({
763
+ host: z.string().min(1).default("127.0.0.1"),
764
+ port: z.number().int().min(0).max(65535).default(8787)
765
+ }).prefault({}),
766
+ statePath: z.string().min(1).default(join(homedir(), ".opencharm", "state.json")),
767
+ adminSocket: z.string().min(1).optional(),
768
+ publicUrl: z.url().optional(),
769
+ maxConnections: z.number().int().min(1).max(1024).default(16),
770
+ allowInsecureRemote: z.boolean().default(false),
771
+ voice: voiceSchema.default({ provider: "fake" }),
772
+ agent: agentSchema.default({ adapter: "fake" }),
773
+ turnTimeoutSeconds: z.number().int().min(5).max(600).default(60),
774
+ logTranscripts: z.boolean().default(false),
775
+ charm: charmSchema.prefault({})
776
+ }).strict();
777
+ function defaultAdminSocket(statePath) {
778
+ return process.platform === "win32" ? "\\\\.\\pipe\\opencharm-charmd" : join(dirname(statePath), "charmd.sock");
779
+ }
780
+ function expandPath(path, base) {
781
+ if (path === "~" || path.startsWith("~/")) return join(homedir(), path.slice(2));
782
+ return resolve(base, path);
783
+ }
784
+ function resolveAgent(agent, base) {
785
+ if (agent.adapter === "openclaw") return {
786
+ ...agent,
787
+ model: `openclaw:${agent.agentId}`
788
+ };
789
+ if (agent.adapter !== "acp") return agent;
790
+ if (Boolean(agent.agent) === Boolean(agent.command)) throw new Error("Invalid charmd config: an ACP agent needs exactly one of \"agent\" (a known name) or \"command\"");
791
+ const known = agent.agent ? ACP_AGENTS[agent.agent] : void 0;
792
+ return {
793
+ adapter: "acp",
794
+ name: agent.agent ?? "acp",
795
+ command: known ? [...known.command] : agent.command ?? [],
796
+ ...known?.sessionMeta ? { sessionMeta: known.sessionMeta } : {},
797
+ ...known?.env ? { env: { ...known.env } } : {},
798
+ ...agent.mode ?? known?.mode ? { mode: agent.mode ?? known?.mode } : {},
799
+ cwd: expandPath(agent.cwd, base),
800
+ charmTools: agent.charmTools,
801
+ ...agent.projects ? { projects: agent.projects.map((p) => expandPath(p, base)) } : {}
802
+ };
803
+ }
804
+ function parseConfig(input, baseDir) {
805
+ const result = schema.safeParse(input);
806
+ if (!result.success) {
807
+ const problems = result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
808
+ throw new Error(`Invalid charmd config: ${problems}`);
809
+ }
810
+ const config = result.data;
811
+ if (!LOOPBACK.has(config.listen.host) && !config.allowInsecureRemote) throw new Error(`Invalid charmd config: listen.host ${config.listen.host} is not loopback; put Caddy in front, or set "allowInsecureRemote": true for a trusted LAN`);
812
+ const statePath = baseDir && !isAbsolute(config.statePath) ? resolve(baseDir, config.statePath) : config.statePath;
813
+ const base = baseDir ?? process.cwd();
814
+ const agent = resolveAgent(config.agent, base);
815
+ return {
816
+ ...config,
817
+ statePath,
818
+ adminSocket: config.adminSocket ?? defaultAdminSocket(statePath),
819
+ agent
820
+ };
821
+ }
822
+ function loadConfig(path, options = {}) {
823
+ if (path) {
824
+ if (!existsSync(path)) throw new Error(`Config file not found: ${path}`);
825
+ return parseConfig(JSON.parse(readFileSync(path, "utf8")), dirname(path));
826
+ }
827
+ const found = (options.searchPaths ?? DEFAULT_SEARCH_PATHS).find((p) => existsSync(p));
828
+ return found ? parseConfig(JSON.parse(readFileSync(found, "utf8")), dirname(found)) : parseConfig({});
829
+ }
830
+ //#endregion
831
+ //#region ../charmd/src/admin/admin-client.ts
832
+ const MAX_REPLY_BYTES = 262144;
833
+ function sendAdmin(socketPath, request) {
834
+ return new Promise((resolve, reject) => {
835
+ const socket = createConnection(socketPath);
836
+ let buffer = "";
837
+ socket.setEncoding("utf8");
838
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
839
+ socket.on("data", (chunk) => {
840
+ buffer += chunk;
841
+ if (buffer.length > MAX_REPLY_BYTES) socket.destroy(/* @__PURE__ */ new Error("admin reply too large"));
842
+ });
843
+ socket.on("end", () => {
844
+ try {
845
+ const reply = JSON.parse(buffer);
846
+ if (reply.ok) resolve(reply.result);
847
+ else reject(new Error(reply.error));
848
+ } catch {
849
+ reject(/* @__PURE__ */ new Error("charmd sent an unreadable reply"));
850
+ }
851
+ });
852
+ socket.on("error", (error) => {
853
+ if (error.code === "ENOENT" || error.code === "ECONNREFUSED") reject(/* @__PURE__ */ new Error(`charmd is not running (no admin socket at ${socketPath}). Start it with: opencharm serve`));
854
+ else if (error.code === "EACCES") reject(/* @__PURE__ */ new Error(`Permission denied on ${socketPath}. Run admin commands as the charmd user: sudo -u charmd opencharm …`));
855
+ else reject(error);
856
+ });
857
+ });
858
+ }
859
+ //#endregion
860
+ //#region package.json
861
+ var version = "0.1.0";
862
+ function channelToCube(value) {
863
+ if (value < 48) return 0;
864
+ if (value < 115) return 1;
865
+ return Math.floor((value - 35) / 40);
866
+ }
867
+ function hexToRgb(hex) {
868
+ const n = Number.parseInt(hex.slice(1), 16);
869
+ return [
870
+ n >> 16 & 255,
871
+ n >> 8 & 255,
872
+ n & 255
873
+ ];
874
+ }
875
+ function rgbTo256([r, g, b]) {
876
+ return 16 + 36 * channelToCube(r) + 6 * channelToCube(g) + channelToCube(b);
877
+ }
878
+ function detectStyleOptions(argv, env, isTTY) {
879
+ return {
880
+ color: isTTY && env.TERM !== "dumb" && !("NO_COLOR" in env) && !argv.includes("--no-color"),
881
+ trueColor: /truecolor|24bit/i.test(env.COLORTERM ?? "")
882
+ };
883
+ }
884
+ function createStyle({ color, trueColor }) {
885
+ const paint = (layer) => (hex) => {
886
+ if (!color) return "";
887
+ const rgb = hexToRgb(hex);
888
+ return trueColor ? `[${layer};2;${rgb.join(";")}m` : `[${layer};5;${rgbTo256(rgb)}m`;
889
+ };
890
+ return {
891
+ fg: paint(38),
892
+ bg: paint(48),
893
+ bold: (text) => color ? `${text}` : text,
894
+ dim: (text) => color ? `${text}` : text,
895
+ reset: color ? `` : ""
896
+ };
897
+ }
898
+ //#endregion
899
+ //#region src/args.ts
900
+ const VALUE_FLAGS = /* @__PURE__ */ new Set([
901
+ "--colour",
902
+ "--color",
903
+ "--config",
904
+ "--agent",
905
+ "--from",
906
+ "--socket",
907
+ "--name",
908
+ "--url",
909
+ "--port",
910
+ "--greeting",
911
+ "--motion"
912
+ ]);
913
+ function isPositional(args, index) {
914
+ const word = args[index];
915
+ const previous = index > 0 ? args[index - 1] : void 0;
916
+ return word !== void 0 && !word.startsWith("-") && !(previous !== void 0 && VALUE_FLAGS.has(previous));
917
+ }
918
+ function firstPositional(args) {
919
+ const index = args.findIndex((_, i) => isPositional(args, i));
920
+ return index >= 0 ? args[index] : void 0;
921
+ }
922
+ function flagValue(args, flag) {
923
+ const index = args.indexOf(flag);
924
+ return index >= 0 ? args[index + 1] : void 0;
925
+ }
926
+ function parseArgs(argv) {
927
+ const at = argv.findIndex((_, i) => isPositional(argv, i));
928
+ return {
929
+ command: at >= 0 ? (argv[at] ?? "").toLowerCase() : "",
930
+ rest: at >= 0 ? [...argv.slice(0, at), ...argv.slice(at + 1)] : [...argv],
931
+ help: argv.includes("-h") || argv.includes("--help"),
932
+ version: argv.includes("-v") || argv.includes("--version")
933
+ };
934
+ }
935
+ //#endregion
936
+ //#region src/prompt.ts
937
+ function createLineQueue(input) {
938
+ const lines = [];
939
+ const waiting = [];
940
+ let ended = false;
941
+ const rl = createInterface({
942
+ input,
943
+ terminal: false
944
+ });
945
+ rl.on("line", (line) => {
946
+ const waiter = waiting.shift();
947
+ if (waiter) waiter(line.trim());
948
+ else lines.push(line.trim());
949
+ });
950
+ rl.on("close", () => {
951
+ ended = true;
952
+ for (const waiter of waiting.splice(0)) waiter("");
953
+ });
954
+ return () => {
955
+ const line = lines.shift();
956
+ if (line !== void 0) return Promise.resolve(line);
957
+ if (ended) return Promise.resolve("");
958
+ return new Promise((resolve) => waiting.push(resolve));
959
+ };
960
+ }
961
+ let pipedLines;
962
+ function promptHidden(question) {
963
+ const input = process.stdin;
964
+ const output = process.stderr;
965
+ if (!input.isTTY) {
966
+ pipedLines ??= createLineQueue(input);
967
+ output.write(question);
968
+ return pipedLines().then((line) => {
969
+ output.write("\n");
970
+ return line;
971
+ });
972
+ }
973
+ return new Promise((resolve) => {
974
+ let value = "";
975
+ output.write(question);
976
+ input.setRawMode(true);
977
+ input.resume();
978
+ input.setEncoding("utf8");
979
+ const onData = (chunk) => {
980
+ for (const char of chunk) {
981
+ if (char === "\r" || char === "\n") {
982
+ input.setRawMode(false);
983
+ input.pause();
984
+ input.off("data", onData);
985
+ output.write("\n");
986
+ resolve(value);
987
+ return;
988
+ }
989
+ if (char === "") {
990
+ input.setRawMode(false);
991
+ process.exit(130);
992
+ }
993
+ if (char === "") value = value.slice(0, -1);
994
+ else value += char;
995
+ }
996
+ };
997
+ input.on("data", onData);
998
+ });
999
+ }
1000
+ //#endregion
1001
+ //#region src/commands/admin.ts
1002
+ const DONE = {
1003
+ lock: "Locked",
1004
+ unlock: "Unblocked and unlocked",
1005
+ revoke: "Revoked"
1006
+ };
1007
+ function fail$2(ctx, message) {
1008
+ ctx.err.write(`${message}\n`);
1009
+ process.exitCode = 1;
1010
+ }
1011
+ async function pair(ctx, args, deps) {
1012
+ const code = firstPositional(args);
1013
+ if (!code) {
1014
+ fail$2(ctx, "Usage: opencharm pair <code> [--name <name>] (the 6-digit code on the charm's screen)");
1015
+ return;
1016
+ }
1017
+ const pin = await deps.prompt("Choose a PIN for the charm (4-12 digits): ");
1018
+ if (pin !== await deps.prompt("Type it again: ")) {
1019
+ fail$2(ctx, "The PINs didn't match. Nothing was changed.");
1020
+ return;
1021
+ }
1022
+ const name = flagValue(args, "--name") ?? "charm";
1023
+ const result = await deps.send(deps.socket, {
1024
+ cmd: "pair",
1025
+ code,
1026
+ pin,
1027
+ name
1028
+ });
1029
+ ctx.out.write(`Paired ${result.name}. The charm now asks for its PIN.\n`);
1030
+ }
1031
+ const DEV_USAGE = "Usage: opencharm dev face <charm> <state> [text] | opencharm dev say <charm> <text> | opencharm dev ask <charm> <question>";
1032
+ function positionals(args) {
1033
+ return args.filter((arg, i) => !arg.startsWith("-") && !VALUE_FLAGS.has(args[i - 1] ?? ""));
1034
+ }
1035
+ async function dev(ctx, args, deps) {
1036
+ const [sub, charm, ...words] = positionals(args);
1037
+ const text = words.join(" ");
1038
+ if (sub === "face" && charm && words[0]) {
1039
+ const [state, ...line] = words;
1040
+ await deps.send(deps.socket, {
1041
+ cmd: "devFace",
1042
+ charm,
1043
+ state,
1044
+ ...line.length ? { text: line.join(" ") } : {}
1045
+ });
1046
+ ctx.out.write(`Face ${state} sent to ${charm}.\n`);
1047
+ return;
1048
+ }
1049
+ if (sub === "ask" && charm && text) {
1050
+ ctx.out.write(`Asking ${charm}: hold the key for yes, press for no (30 s).\n`);
1051
+ const { answer } = await deps.send(deps.socket, {
1052
+ cmd: "devAsk",
1053
+ charm,
1054
+ text
1055
+ });
1056
+ ctx.out.write(answer === "yes" ? `${charm} said yes.\n` : `${charm} said no (or nothing).\n`);
1057
+ return;
1058
+ }
1059
+ if (sub === "say" && charm && text) {
1060
+ await deps.send(deps.socket, {
1061
+ cmd: "devSay",
1062
+ charm,
1063
+ text
1064
+ });
1065
+ ctx.out.write(`${charm} is saying it.\n`);
1066
+ return;
1067
+ }
1068
+ fail$2(ctx, DEV_USAGE);
1069
+ }
1070
+ async function look(ctx, args, deps) {
1071
+ const flags = {
1072
+ colour: flagValue(args, "--colour") ?? flagValue(args, "--color"),
1073
+ greeting: flagValue(args, "--greeting"),
1074
+ motion: flagValue(args, "--motion")
1075
+ };
1076
+ const update = Object.fromEntries(Object.entries(flags).filter(([, value]) => value !== void 0));
1077
+ const result = await deps.send(deps.socket, {
1078
+ cmd: "look",
1079
+ ...update
1080
+ });
1081
+ const sleep = result.sleepAfterMinutes === 0 ? "never sleeps" : `sleeps after ${result.sleepAfterMinutes} min`;
1082
+ ctx.out.write(`${result.name} ${result.colour} ${result.motion} motion ${sleep}\n${result.greeting ? JSON.stringify(result.greeting) : "(no greeting)"}\n`);
1083
+ if (Object.keys(update).length === 0) return;
1084
+ ctx.out.write(`${result.connected === 0 ? "No charm is unlocked right now: it gets the look when it unlocks." : `Sent to ${result.connected} charm${result.connected === 1 ? "" : "s"}.`} It lasts until charmd restarts; to keep it, set "charm" in opencharm.json.\n`);
1085
+ }
1086
+ function printStatus(ctx, charms) {
1087
+ if (charms.length === 0) {
1088
+ ctx.out.write("No charms paired yet. Start one and run: opencharm pair <code>\n");
1089
+ return;
1090
+ }
1091
+ for (const charm of charms) {
1092
+ const note = charm.blocked ? "blocked (opencharm unlock to clear)" : charm.failedTries > 0 ? `${charm.failedTries} wrong PIN(s)` : "";
1093
+ ctx.out.write(`${charm.name.padEnd(16)} ${charm.state.padEnd(10)} ${note}\n`.trimEnd() + "\n");
1094
+ }
1095
+ }
1096
+ async function runAdminCommand(ctx, command, args, deps) {
1097
+ try {
1098
+ if (command === "pair") {
1099
+ await pair(ctx, args, deps);
1100
+ return;
1101
+ }
1102
+ if (command === "dev") {
1103
+ await dev(ctx, args, deps);
1104
+ return;
1105
+ }
1106
+ if (command === "look") {
1107
+ await look(ctx, args, deps);
1108
+ return;
1109
+ }
1110
+ if (command === "status") {
1111
+ const { charms } = await deps.send(deps.socket, { cmd: "status" });
1112
+ printStatus(ctx, charms);
1113
+ return;
1114
+ }
1115
+ const charm = firstPositional(args);
1116
+ if (!charm) {
1117
+ fail$2(ctx, `Usage: opencharm ${command} <charm> (see: opencharm status)`);
1118
+ return;
1119
+ }
1120
+ const { connected } = await deps.send(deps.socket, {
1121
+ cmd: command,
1122
+ charm
1123
+ });
1124
+ ctx.out.write(`${DONE[command] ?? "Done"}: ${charm}${connected === 0 ? " (not connected right now)" : ""}.\n`);
1125
+ } catch (error) {
1126
+ fail$2(ctx, error instanceof Error ? error.message : String(error));
1127
+ }
1128
+ }
1129
+ //#endregion
1130
+ //#region src/face-lookup.ts
1131
+ const VARIATION_SELECTOR = /︎/g;
1132
+ const DEFAULT_COLOUR_ID = "white";
1133
+ function glyph(value) {
1134
+ return value.replace(VARIATION_SELECTOR, "");
1135
+ }
1136
+ function findFace(id) {
1137
+ const wanted = id.toLowerCase();
1138
+ return facesData.faces.find((face) => face.id === id || face.t.toLowerCase() === wanted);
1139
+ }
1140
+ function randomFace() {
1141
+ return facesData.faces[Math.floor(Math.random() * facesData.faces.length)];
1142
+ }
1143
+ function faceText(face) {
1144
+ return glyph(face.text);
1145
+ }
1146
+ function pickColour(args, warn) {
1147
+ const flagAt = args.indexOf("--colour") >= 0 ? args.indexOf("--colour") : args.indexOf("--color");
1148
+ const id = flagAt >= 0 ? (args[flagAt + 1] ?? "").toLowerCase() : DEFAULT_COLOUR_ID;
1149
+ const found = facesData.colours.find((colour) => colour.id === id);
1150
+ if (found) return found;
1151
+ const fallback = facesData.colours[0];
1152
+ if (!fallback) throw new Error("faces.json has no colours");
1153
+ warn(`Unknown colour "${id}". Try: ${facesData.colours.map((c) => c.id).join(", ")}. Using white.`);
1154
+ return fallback;
1155
+ }
1156
+ //#endregion
1157
+ //#region src/screen.ts
1158
+ const INNER = 21;
1159
+ const ROWS = 5;
1160
+ const EYE_LEFT = 5;
1161
+ const EYE_RIGHT = 15;
1162
+ const MIDDLE = 10;
1163
+ const EYE_ROW = 2;
1164
+ const MOUTH_ROW = 3;
1165
+ const BLINK_GLYPH = "−";
1166
+ const SCREEN_BLACK = "#000000";
1167
+ function renderScreen(face, colour, style, signal, { blink = false } = {}) {
1168
+ const ring = face.fx === "ask" ? style.fg(signal) : style.fg(colour.c);
1169
+ const ink = style.fg(colour.g);
1170
+ const black = style.bg(SCREEN_BLACK);
1171
+ const left = blink ? BLINK_GLYPH : glyph(face.L);
1172
+ const right = blink ? BLINK_GLYPH : glyph(face.R);
1173
+ const lines = [`${ring}╭${"─".repeat(INNER)}╮${style.reset}`];
1174
+ for (let row = 0; row < ROWS; row++) {
1175
+ const cells = new Array(INNER).fill(" ");
1176
+ if (row === EYE_ROW) {
1177
+ cells[EYE_LEFT] = left;
1178
+ cells[EYE_RIGHT] = right;
1179
+ }
1180
+ if (row === MOUTH_ROW && face.M) cells[MIDDLE] = face.M;
1181
+ if (row === 0 && face.fx === "z") cells[18] = "z";
1182
+ lines.push(`${ring}│${style.reset}${black}${ink}${style.bold(cells.join(""))}${style.reset}${ring}│${style.reset}`);
1183
+ }
1184
+ lines.push(`${ring}╰${"─".repeat(INNER)}╯${style.reset}`);
1185
+ return lines;
1186
+ }
1187
+ //#endregion
1188
+ //#region src/animate.ts
1189
+ const BLINK_MS = 130;
1190
+ const OPEN_MIN_MS = 900;
1191
+ const OPEN_JITTER_MS = 700;
1192
+ function sleep(ms) {
1193
+ return new Promise((resolve) => setTimeout(resolve, ms));
1194
+ }
1195
+ function redraw(lines) {
1196
+ return `[${lines.length}A${lines.map((line) => ` ${line}`).join("\n")}\n`;
1197
+ }
1198
+ async function animateFace(face, colour, { out, style, signal, canAnimate, durationMs }) {
1199
+ const draw = (blink) => renderScreen(face, colour, style, signal, { blink });
1200
+ out.write(`${draw(false).map((line) => ` ${line}`).join("\n")}\n`);
1201
+ if (!canAnimate) return;
1202
+ out.write(`[?25l`);
1203
+ const restoreCursor = () => out.write(`[?25h`);
1204
+ process.once("SIGINT", () => {
1205
+ restoreCursor();
1206
+ process.exit(130);
1207
+ });
1208
+ const end = Date.now() + durationMs;
1209
+ let blink = false;
1210
+ while (Date.now() < end) {
1211
+ await sleep(blink ? BLINK_MS : OPEN_MIN_MS + Math.random() * OPEN_JITTER_MS);
1212
+ blink = !blink;
1213
+ out.write(redraw(draw(blink)));
1214
+ }
1215
+ out.write(redraw(draw(false)));
1216
+ restoreCursor();
1217
+ }
1218
+ //#endregion
1219
+ //#region src/commands/face.ts
1220
+ const FACE_MS = 2200;
1221
+ async function runFace(ctx, args) {
1222
+ const colour = pickColour(args, (message) => ctx.err.write(`${message}\n`));
1223
+ const id = firstPositional(args);
1224
+ const face = id ? findFace(id) : randomFace();
1225
+ if (!face) {
1226
+ ctx.out.write(`No mood called "${id ?? ""}". Try: ${facesData.faces.map((f) => f.id).join(", ")}\n`);
1227
+ process.exitCode = 1;
1228
+ return;
1229
+ }
1230
+ ctx.out.write("\n");
1231
+ await animateFace(face, colour, {
1232
+ ...ctx,
1233
+ durationMs: FACE_MS
1234
+ });
1235
+ ctx.out.write(`\n ${ctx.style.bold(face.t)} ${ctx.style.dim(faceText(face))}\n\n`);
1236
+ }
1237
+ //#endregion
1238
+ //#region src/commands/faces.ts
1239
+ function runFaces(ctx, args) {
1240
+ const { fg, bg, bold, dim, reset } = ctx.style;
1241
+ const colour = pickColour(args, (message) => ctx.err.write(`${message}\n`));
1242
+ ctx.out.write(`\n ${bold("Moods")} ${dim("(two eyes and an optional mouth, typed)")}\n\n`);
1243
+ for (const face of facesData.faces) {
1244
+ const text = faceText(face).padEnd(7);
1245
+ ctx.out.write(` ${fg(colour.g)}${bg("#000000")} ${bold(text)} ${reset} ${face.t.padEnd(10)} ${dim(face.id)}\n`);
1246
+ }
1247
+ ctx.out.write(`\n ${dim("Write your own: two characters for eyes, one for a mouth. Share it as text.")}\n\n`);
1248
+ }
1249
+ //#endregion
1250
+ //#region src/links.ts
1251
+ const LINKS = {
1252
+ site: "https://opencharm.dev",
1253
+ repo: "https://github.com/opencharm-labs/opencharm",
1254
+ board: "https://www.waveshare.com/esp32-s3-touch-amoled-2.16.htm"
1255
+ };
1256
+ //#endregion
1257
+ //#region src/commands/hardware.ts
1258
+ function runHardware(ctx) {
1259
+ const { bold, dim } = ctx.style;
1260
+ ctx.out.write(`
1261
+ ${bold("What you need")}
1262
+
1263
+ 1. Waveshare ESP32-S3-Touch-AMOLED-2.16, the version with a battery
1264
+ 2.16" 480×480 AMOLED, two mics, speaker amp, battery, case.
1265
+ $31.99 at Waveshare's store in September 2026 (check the store; shipping extra).
1266
+ ${LINKS.board}
1267
+ 2. A small speaker, 8 ohm 1-2 W with a 2-pin lead (not in the box).
1268
+ 3. A USB-C cable that carries data.
1269
+ 4. Optional: print your own shell (3 parts, no supports, strap from the seam).
1270
+ STL files: ${LINKS.repo}/tree/main/hardware/stl/print
1271
+
1272
+ ${dim(`Firmware and charmd are in development. Follow along at ${LINKS.site}`)}
1273
+ `);
1274
+ }
1275
+ //#endregion
1276
+ //#region src/commands/hello.ts
1277
+ const HELLO_MS = 2600;
1278
+ async function runHello(ctx, args) {
1279
+ const colour = pickColour(args, (message) => ctx.err.write(`${message}\n`));
1280
+ const face = findFace("happy");
1281
+ if (!face) throw new Error("faces.json lost the \"happy\" face");
1282
+ const { bold, dim } = ctx.style;
1283
+ ctx.out.write("\n");
1284
+ await animateFace(face, colour, {
1285
+ ...ctx,
1286
+ durationMs: HELLO_MS
1287
+ });
1288
+ ctx.out.write(`
1289
+ ${bold("OpenCharm")} ${dim(`v${ctx.version}`)}
1290
+ The open-source charm for your AI agent.
1291
+
1292
+ A small device that becomes the face and voice of the agent you already run
1293
+ (Hermes Agent or OpenClaw). A face you can type, one key, about $32 of hardware.
1294
+
1295
+ ${bold("Try")}
1296
+ npx opencharm faces all ${facesData.faces.length} moods
1297
+ npx opencharm face learned one mood, big
1298
+ npx opencharm states what your agent is doing → which face
1299
+ npx opencharm hardware what to buy and print
1300
+ npx opencharm serve start charmd, the charm daemon
1301
+
1302
+ ${bold("Links")}
1303
+ ${LINKS.site}
1304
+ ${LINKS.repo}
1305
+ `);
1306
+ }
1307
+ //#endregion
1308
+ //#region src/commands/help.ts
1309
+ function runHelp(ctx) {
1310
+ const { bold, dim } = ctx.style;
1311
+ ctx.out.write(`
1312
+ ${bold("opencharm")} ${dim(`v${ctx.version}`)} The open-source charm for your AI agent.
1313
+
1314
+ Usage
1315
+ opencharm say hello
1316
+ opencharm faces list all moods
1317
+ opencharm face [mood] show one mood (random if omitted)
1318
+ opencharm states agent states and their faces
1319
+ opencharm hardware what to buy and print
1320
+
1321
+ charmd, the charm daemon (runs next to your agent)
1322
+ opencharm init [dir] clone the starter workspace for your agent [--agent claude|codex|gemini|goose|hermes|openclaw] [--from <git url>] [--name <name>] [--colour <colour>]
1323
+ opencharm setup --domain <d> install charmd as a service (Linux, sudo) [--dry-run]
1324
+ opencharm serve start charmd [--config <file>]
1325
+ opencharm sim the charm on screen, in your browser [--url <ws>] [--port <n>]
1326
+ opencharm pair <code> pair the charm showing <code> [--name <name>]
1327
+ opencharm status paired charms and their state
1328
+ opencharm lock <charm> lock a charm now
1329
+ opencharm unlock <charm> clear a block and unlock
1330
+ opencharm revoke <charm> forget a charm (it must pair again)
1331
+ opencharm look show or change the charm's look until charmd restarts [--colour <c>] [--greeting <text>] [--motion full|calm]
1332
+ opencharm dev face <charm> <state> [text] show a face (testing)
1333
+ opencharm dev say <charm> <text> speak through it (testing)
1334
+ opencharm dev ask <charm> <question> ask it; hold = yes, press = no (testing)
1335
+ opencharm mcp the charm's tools for an agent (MCP over stdio) [--config <file>]
1336
+
1337
+ Options
1338
+ --colour <name> white, cobalt, lime, lilac, sun, coal
1339
+ --no-anim no blinking
1340
+ --no-color plain text (also respects NO_COLOR)
1341
+ -v, --version print the version
1342
+ -h, --help this help
1343
+
1344
+ ${LINKS.site}
1345
+ `);
1346
+ }
1347
+ //#endregion
1348
+ //#region src/commands/init.ts
1349
+ const STARTER = "https://github.com/opencharm-labs/opencharm-starter.git";
1350
+ function checkLook(options) {
1351
+ const result = z.object({
1352
+ name: lookFields.name,
1353
+ colour: lookFields.colour
1354
+ }).safeParse({
1355
+ name: options.name,
1356
+ colour: options.colour
1357
+ });
1358
+ if (!result.success) throw new Error(result.error.issues.map((issue) => `--${issue.path.join(".")}: ${issue.message}`).join("; "));
1359
+ const { name, colour } = result.data;
1360
+ return {
1361
+ ...name ? { name } : {},
1362
+ ...colour ? { colour } : {}
1363
+ };
1364
+ }
1365
+ function initWorkspace(target, options) {
1366
+ const from = options.from ?? STARTER;
1367
+ const agent = options.agent ?? "claude";
1368
+ if (!ACP_AGENT_NAMES.includes(agent)) throw new Error(`Unknown agent "${agent}": choose ${ACP_AGENT_NAMES.join(", ")}`);
1369
+ const look = checkLook(options);
1370
+ if (existsSync(target) && readdirSync(target).length > 0) throw new Error(`${target} is not empty; choose a new folder`);
1371
+ const clone = spawnSync("git", [
1372
+ "clone",
1373
+ "--quiet",
1374
+ "--depth",
1375
+ "1",
1376
+ "--origin",
1377
+ "upstream",
1378
+ from,
1379
+ target
1380
+ ], { encoding: "utf8" });
1381
+ if (clone.error) throw new Error("git not found: install git, then run this again");
1382
+ if (clone.status !== 0) throw new Error(`Couldn't clone ${from}: ${clone.stderr.trim().split("\n").at(-1) ?? ""}`);
1383
+ const path = join(target, "opencharm.json");
1384
+ if (!existsSync(path)) throw new Error(`${from} has no opencharm.json: is it an OpenCharm starter? (${target} was cloned; delete it)`);
1385
+ const config = JSON.parse(readFileSync(path, "utf8"));
1386
+ const voice = options.platform === "darwin" ? "local" : "fake";
1387
+ const hasLook = Object.keys(look).length > 0;
1388
+ if (config.voice?.provider === voice && config.agent?.agent === agent && !hasLook) return;
1389
+ config.voice = {
1390
+ ...config.voice,
1391
+ provider: voice
1392
+ };
1393
+ config.agent = {
1394
+ ...config.agent,
1395
+ agent
1396
+ };
1397
+ if (hasLook) config.charm = {
1398
+ ...config.charm,
1399
+ ...look
1400
+ };
1401
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
1402
+ }
1403
+ function runInit(ctx, args) {
1404
+ const dir = resolve(firstPositional(args) ?? "my-charm");
1405
+ const name = flagValue(args, "--name");
1406
+ try {
1407
+ initWorkspace(dir, {
1408
+ from: flagValue(args, "--from"),
1409
+ agent: flagValue(args, "--agent"),
1410
+ name,
1411
+ colour: flagValue(args, "--colour") ?? flagValue(args, "--color"),
1412
+ platform: process.platform
1413
+ });
1414
+ } catch (error) {
1415
+ ctx.err.write(`${error instanceof Error ? error.message : String(error)}\n`);
1416
+ process.exitCode = 1;
1417
+ return;
1418
+ }
1419
+ const charm = name ?? readAgentName(join(dir, "charm"));
1420
+ ctx.out.write(`
1421
+ ${ctx.style.bold("Your charm's workspace is ready")} ${ctx.style.dim(dir)}
1422
+
1423
+ cd ${dir}
1424
+ opencharm serve start charmd here; it runs your agent in charm/
1425
+ opencharm sim no charm yet? it appears in your browser (another terminal)
1426
+ opencharm pair <code> the code on the charm (or the emulator); you choose its PIN
1427
+
1428
+ The charm is called ${charm}: its name, colour and greeting are in opencharm.json ("charm"). README.md has the voice setup.
1429
+ `);
1430
+ }
1431
+ //#endregion
1432
+ //#region src/mcp/server.ts
1433
+ const VERSIONS = [
1434
+ "2025-06-18",
1435
+ "2025-03-26",
1436
+ "2024-11-05"
1437
+ ];
1438
+ const FACES = facesData.states.map((state) => state.id);
1439
+ const COLOURS = facesData.colours.map((colour) => colour.id);
1440
+ const line = z.string().min(1).max(200);
1441
+ const label = z.string().min(1).max(12);
1442
+ const TOOLS = [
1443
+ {
1444
+ name: "say",
1445
+ description: "Speak a short message out loud on the charm when it is idle (a reminder from a scheduled task, a heads-up). During your reply it is refused: put the words in your reply instead.",
1446
+ inputSchema: {
1447
+ type: "object",
1448
+ properties: { text: {
1449
+ type: "string",
1450
+ description: "One or two short spoken sentences, no markdown."
1451
+ } },
1452
+ required: ["text"]
1453
+ },
1454
+ args: z.object({ text: z.string().min(1).max(500) }),
1455
+ run: async (args, send) => {
1456
+ await send({
1457
+ cmd: "devSay",
1458
+ text: args.text
1459
+ });
1460
+ return "Said on the charm.";
1461
+ }
1462
+ },
1463
+ {
1464
+ name: "show_face",
1465
+ description: "Show a face on the charm's screen, with an optional short line under it. Faces are agent states: done, thinking, working, stuck, learned, noted, asleep and more (see the enum).",
1466
+ inputSchema: {
1467
+ type: "object",
1468
+ properties: {
1469
+ face: {
1470
+ type: "string",
1471
+ enum: FACES
1472
+ },
1473
+ line: {
1474
+ type: "string",
1475
+ description: "Optional, up to 200 characters."
1476
+ }
1477
+ },
1478
+ required: ["face"]
1479
+ },
1480
+ args: z.object({
1481
+ face: z.enum(FACES),
1482
+ line: line.optional()
1483
+ }),
1484
+ run: async (args, send) => {
1485
+ await send({
1486
+ cmd: "devFace",
1487
+ state: args.face,
1488
+ ...args.line ? { text: args.line } : {}
1489
+ });
1490
+ return `Showing ${String(args.face)}.`;
1491
+ }
1492
+ },
1493
+ {
1494
+ name: "ask",
1495
+ description: "Ask the person holding the charm a yes/no question before doing something (hold the key = yes, press = no, no answer in 30 s = no). Returns \"yes\" or \"no\". Keep the question short: it's read on a small screen.",
1496
+ inputSchema: {
1497
+ type: "object",
1498
+ properties: {
1499
+ question: {
1500
+ type: "string",
1501
+ description: "Up to 200 characters, e.g. \"Send the email to Ada?\""
1502
+ },
1503
+ yes: {
1504
+ type: "string",
1505
+ description: "Label for yes, up to 12 characters (default ALLOW)."
1506
+ },
1507
+ no: {
1508
+ type: "string",
1509
+ description: "Label for no, up to 12 characters (default NO)."
1510
+ }
1511
+ },
1512
+ required: ["question"]
1513
+ },
1514
+ args: z.object({
1515
+ question: line,
1516
+ yes: label.optional(),
1517
+ no: label.optional()
1518
+ }),
1519
+ run: async (args, send) => {
1520
+ const { answer } = await send({
1521
+ cmd: "devAsk",
1522
+ text: args.question,
1523
+ ...args.yes ? { yes: args.yes } : {},
1524
+ ...args.no ? { no: args.no } : {}
1525
+ });
1526
+ return answer;
1527
+ }
1528
+ },
1529
+ {
1530
+ name: "notify",
1531
+ description: "Light the charm's orange \"it needs you\" face with a short line, for something that needs the person's attention. It stays until they press the key.",
1532
+ inputSchema: {
1533
+ type: "object",
1534
+ properties: { text: {
1535
+ type: "string",
1536
+ description: "Up to 200 characters."
1537
+ } },
1538
+ required: ["text"]
1539
+ },
1540
+ args: z.object({ text: line }),
1541
+ run: async (args, send) => {
1542
+ await send({
1543
+ cmd: "devFace",
1544
+ state: "needs_you",
1545
+ text: args.text
1546
+ });
1547
+ return "The charm is showing it.";
1548
+ }
1549
+ },
1550
+ {
1551
+ name: "set_look",
1552
+ description: "Change how the charm looks when the person asks (e.g. \"make yourself blue\"): its glyph colour, its greeting after unlock, or calm motion. Lasts until charmd restarts.",
1553
+ inputSchema: {
1554
+ type: "object",
1555
+ properties: {
1556
+ colour: {
1557
+ type: "string",
1558
+ enum: COLOURS,
1559
+ description: "cobalt is blue, lime green, lilac purple, sun yellow; white and coal light the glyphs white."
1560
+ },
1561
+ greeting: {
1562
+ type: "string",
1563
+ description: "Said after unlock, up to 40 characters; \"\" for none."
1564
+ },
1565
+ motion: {
1566
+ type: "string",
1567
+ enum: ["full", "calm"],
1568
+ description: "calm: no glances or bounces, for less movement."
1569
+ }
1570
+ }
1571
+ },
1572
+ args: z.object({
1573
+ colour: z.enum(COLOURS).optional(),
1574
+ greeting: z.string().max(40).optional(),
1575
+ motion: z.enum(["full", "calm"]).optional()
1576
+ }).strict(),
1577
+ run: async (args, send) => {
1578
+ const look = await send({
1579
+ cmd: "look",
1580
+ ...args,
1581
+ source: "agent"
1582
+ });
1583
+ return `The charm is now ${look.colour}, ${look.motion} motion, greeting "${look.greeting}".`;
1584
+ }
1585
+ }
1586
+ ];
1587
+ const reply = (id, result) => JSON.stringify({
1588
+ jsonrpc: "2.0",
1589
+ id,
1590
+ result
1591
+ });
1592
+ const fail$1 = (id, code, message) => JSON.stringify({
1593
+ jsonrpc: "2.0",
1594
+ id,
1595
+ error: {
1596
+ code,
1597
+ message
1598
+ }
1599
+ });
1600
+ function createCharmMcp({ version, send }) {
1601
+ async function call(id, params) {
1602
+ const { name, arguments: raw } = params ?? {};
1603
+ const tool = TOOLS.find((t) => t.name === name);
1604
+ if (!tool) return fail$1(id, -32602, `Unknown tool: ${String(name)}`);
1605
+ const args = tool.args.safeParse(raw ?? {});
1606
+ if (!args.success) return fail$1(id, -32602, `Invalid arguments for ${tool.name}: ${args.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
1607
+ try {
1608
+ return reply(id, { content: [{
1609
+ type: "text",
1610
+ text: await tool.run(args.data, send)
1611
+ }] });
1612
+ } catch (error) {
1613
+ return reply(id, {
1614
+ isError: true,
1615
+ content: [{
1616
+ type: "text",
1617
+ text: error instanceof Error ? error.message : String(error)
1618
+ }]
1619
+ });
1620
+ }
1621
+ }
1622
+ async function handle(raw) {
1623
+ let parsed;
1624
+ try {
1625
+ parsed = JSON.parse(raw);
1626
+ } catch {
1627
+ return fail$1(null, -32700, "Parse error");
1628
+ }
1629
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return fail$1(null, -32600, "Invalid request: send one JSON-RPC object per line");
1630
+ const { id, method, params } = parsed;
1631
+ if (id === void 0 || id === null) return void 0;
1632
+ switch (method) {
1633
+ case "initialize": {
1634
+ const asked = params?.protocolVersion;
1635
+ return reply(id, {
1636
+ protocolVersion: asked && VERSIONS.includes(asked) ? asked : VERSIONS[0],
1637
+ capabilities: { tools: { listChanged: false } },
1638
+ serverInfo: {
1639
+ name: "opencharm",
1640
+ version
1641
+ },
1642
+ instructions: "Tools for the charm: a small device with a face, a speaker and one key that the person carries. Use them sparingly; everything is seen or heard by the person and anyone nearby."
1643
+ });
1644
+ }
1645
+ case "ping": return reply(id, {});
1646
+ case "tools/list": return reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({
1647
+ name,
1648
+ description,
1649
+ inputSchema
1650
+ })) });
1651
+ case "tools/call": return call(id, params);
1652
+ default: return fail$1(id, -32601, `Method not found: ${String(method)}`);
1653
+ }
1654
+ }
1655
+ return { handle };
1656
+ }
1657
+ //#endregion
1658
+ //#region src/commands/mcp.ts
1659
+ async function serveMcp(input, output, deps) {
1660
+ const mcp = createCharmMcp(deps);
1661
+ const pending = [];
1662
+ for await (const line of createInterface({
1663
+ input,
1664
+ crlfDelay: Infinity
1665
+ })) {
1666
+ if (!line.trim()) continue;
1667
+ pending.push(mcp.handle(line).then((reply) => {
1668
+ if (reply !== void 0) output.write(`${reply}\n`);
1669
+ }));
1670
+ }
1671
+ await Promise.all(pending);
1672
+ }
1673
+ //#endregion
1674
+ //#region ../charmd/src/admin/admin-actions.ts
1675
+ const pairInput = z.object({
1676
+ code: z.string().regex(/^\d{6}$/, "the code has 6 digits"),
1677
+ pin: z.string().regex(/^\d{4,12}$/, "the PIN is 4 to 12 digits"),
1678
+ name: z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/i, "names are letters, digits and hyphens").default("charm")
1679
+ });
1680
+ const charmInput = z.object({ charm: z.string().min(1) });
1681
+ const anyCharmInput = z.object({ charm: z.string().min(1).optional() });
1682
+ const devFaceInput = anyCharmInput.extend({
1683
+ state: z.enum(FACE_STATES, { error: "unknown face state" }),
1684
+ text: z.string().max(200).optional()
1685
+ });
1686
+ const devSayInput = anyCharmInput.extend({ text: z.string().min(1).max(500) });
1687
+ const lookInput = z.object({
1688
+ ...lookFields,
1689
+ source: z.literal("agent").optional()
1690
+ }).strict();
1691
+ const devAskInput = anyCharmInput.extend({
1692
+ text: z.string().min(1).max(200),
1693
+ yes: z.string().min(1).max(12).optional(),
1694
+ no: z.string().min(1).max(12).optional()
1695
+ });
1696
+ function settled(work) {
1697
+ try {
1698
+ return Promise.resolve(work());
1699
+ } catch (error) {
1700
+ return Promise.reject(error instanceof Error ? error : new Error(String(error)));
1701
+ }
1702
+ }
1703
+ function parseInput(schema, input) {
1704
+ const result = schema.safeParse(input);
1705
+ if (!result.success) throw new Error(result.error.issues.map((i) => i.message).join("; "));
1706
+ return result.data;
1707
+ }
1708
+ function createAdminActions({ store, pairing, sessions, look, agentCanChangeLook }) {
1709
+ function sessionsOf(charmId) {
1710
+ return [...sessions()].filter((s) => s.charmId === charmId && s.state !== "closed");
1711
+ }
1712
+ function requireCharm(ref) {
1713
+ const charm = store.find(ref);
1714
+ if (!charm) throw new Error(`No charm called "${ref}". See: opencharm status`);
1715
+ return charm;
1716
+ }
1717
+ function requireLive(ref) {
1718
+ if (ref === void 0) {
1719
+ const latest = [...sessions()].filter((s) => s.state === "unlocked").at(-1);
1720
+ if (!latest) throw new Error("No charm is connected and unlocked.");
1721
+ return [latest];
1722
+ }
1723
+ const live = sessionsOf(requireCharm(ref).id).filter((s) => s.state === "unlocked");
1724
+ if (live.length === 0) throw new Error(`"${ref}" is not connected and unlocked.`);
1725
+ return live;
1726
+ }
1727
+ return {
1728
+ async pair(input) {
1729
+ const { code, pin, name } = parseInput(pairInput, input);
1730
+ if (store.find(name)) throw new Error(`A charm called "${name}" already exists.`);
1731
+ const connectionId = pairing.claim(code);
1732
+ const session = connectionId ? [...sessions()].find((s) => s.connectionId === connectionId) : void 0;
1733
+ if (!session) throw new Error("That code is wrong or expired. Use the code on the charm's screen now.");
1734
+ await session.completePairing(name, pin);
1735
+ const charm = requireCharm(name);
1736
+ return {
1737
+ id: charm.id,
1738
+ name: charm.name
1739
+ };
1740
+ },
1741
+ lock(input) {
1742
+ return settled(() => {
1743
+ const live = sessionsOf(requireCharm(parseInput(charmInput, input).charm).id);
1744
+ for (const session of live) session.lockRemote();
1745
+ return { connected: live.length };
1746
+ });
1747
+ },
1748
+ unlock(input) {
1749
+ return settled(() => {
1750
+ const charm = requireCharm(parseInput(charmInput, input).charm);
1751
+ store.put({
1752
+ ...charm,
1753
+ blocked: false,
1754
+ failedTries: 0
1755
+ });
1756
+ const live = sessionsOf(charm.id);
1757
+ for (const session of live) session.unlockByAdmin();
1758
+ return { connected: live.length };
1759
+ });
1760
+ },
1761
+ revoke(input) {
1762
+ return settled(() => {
1763
+ const charm = requireCharm(parseInput(charmInput, input).charm);
1764
+ store.remove(charm.id);
1765
+ const live = sessionsOf(charm.id);
1766
+ for (const session of live) session.revoke();
1767
+ return { connected: live.length };
1768
+ });
1769
+ },
1770
+ devFace(input) {
1771
+ return settled(() => {
1772
+ const { charm: ref, state, text } = parseInput(devFaceInput, input);
1773
+ const live = requireLive(ref);
1774
+ for (const session of live) session.devFace(state, text);
1775
+ return { connected: live.length };
1776
+ });
1777
+ },
1778
+ devSay(input) {
1779
+ return settled(() => {
1780
+ const { charm: ref, text } = parseInput(devSayInput, input);
1781
+ const live = requireLive(ref);
1782
+ for (const session of live) session.devSay(text);
1783
+ return { connected: live.length };
1784
+ });
1785
+ },
1786
+ async devAsk(input) {
1787
+ const { charm: ref, text, yes, no } = parseInput(devAskInput, input);
1788
+ const session = requireLive(ref).at(-1);
1789
+ if (session?.state !== "unlocked") throw new Error("The charm must be unlocked first.");
1790
+ return { answer: await session.devAsk(text, {
1791
+ ...yes ? { yes } : {},
1792
+ ...no ? { no } : {}
1793
+ }) ? "yes" : "no" };
1794
+ },
1795
+ look(input) {
1796
+ return settled(() => {
1797
+ const { source, ...update } = parseInput(lookInput, input);
1798
+ if (Object.keys(update).length === 0) return {
1799
+ ...look.current(),
1800
+ connected: 0
1801
+ };
1802
+ if (source === "agent" && !agentCanChangeLook) throw new Error("The agent may not change the charm's look here (charm.agentCanChangeLook is false in opencharm.json).");
1803
+ const next = look.apply(update);
1804
+ const message = look.message();
1805
+ const live = [...sessions()].filter((s) => s.state === "unlocked");
1806
+ for (const session of live) session.sendLook(message);
1807
+ return {
1808
+ ...next,
1809
+ connected: live.length
1810
+ };
1811
+ });
1812
+ },
1813
+ status() {
1814
+ return { charms: store.list().map((charm) => {
1815
+ const live = sessionsOf(charm.id).at(-1);
1816
+ return {
1817
+ id: charm.id,
1818
+ name: charm.name,
1819
+ blocked: charm.blocked,
1820
+ failedTries: charm.failedTries,
1821
+ connected: live !== void 0,
1822
+ state: live?.state ?? "offline"
1823
+ };
1824
+ }) };
1825
+ }
1826
+ };
1827
+ }
1828
+ //#endregion
1829
+ //#region ../charmd/src/admin/admin-server.ts
1830
+ const MAX_REQUEST_BYTES = 16384;
1831
+ const COMMANDS = [
1832
+ "pair",
1833
+ "lock",
1834
+ "unlock",
1835
+ "revoke",
1836
+ "status",
1837
+ "devFace",
1838
+ "devSay",
1839
+ "devAsk",
1840
+ "look"
1841
+ ];
1842
+ function isCommand(value) {
1843
+ return typeof value === "string" && COMMANDS.includes(value);
1844
+ }
1845
+ function socketInUse(path) {
1846
+ return new Promise((resolve) => {
1847
+ const probe = createConnection(path);
1848
+ probe.on("connect", () => {
1849
+ probe.destroy();
1850
+ resolve(true);
1851
+ });
1852
+ probe.on("error", () => resolve(false));
1853
+ });
1854
+ }
1855
+ async function run$2(actions, request) {
1856
+ const { cmd, ...args } = typeof request === "object" && request !== null ? request : {};
1857
+ if (!isCommand(cmd)) throw new Error(`Unknown command ${JSON.stringify(cmd)}`);
1858
+ return cmd === "status" ? actions.status() : actions[cmd](args);
1859
+ }
1860
+ function handle(socket, actions) {
1861
+ let buffer = "";
1862
+ socket.setEncoding("utf8");
1863
+ socket.on("data", (chunk) => {
1864
+ buffer += chunk;
1865
+ if (buffer.length > MAX_REQUEST_BYTES) {
1866
+ socket.destroy();
1867
+ return;
1868
+ }
1869
+ const newline = buffer.indexOf("\n");
1870
+ if (newline < 0) return;
1871
+ let request;
1872
+ try {
1873
+ request = JSON.parse(buffer.slice(0, newline));
1874
+ } catch {
1875
+ socket.end(JSON.stringify({
1876
+ ok: false,
1877
+ error: "Request is not JSON"
1878
+ }));
1879
+ return;
1880
+ }
1881
+ run$2(actions, request).then((result) => socket.end(JSON.stringify({
1882
+ ok: true,
1883
+ result
1884
+ })), (error) => socket.end(JSON.stringify({
1885
+ ok: false,
1886
+ error: error instanceof Error ? error.message : String(error)
1887
+ })));
1888
+ });
1889
+ socket.on("error", () => socket.destroy());
1890
+ }
1891
+ async function startAdminServer(path, actions) {
1892
+ if (process.platform !== "win32") mkdirSync(dirname(path), {
1893
+ recursive: true,
1894
+ mode: 448
1895
+ });
1896
+ if (process.platform !== "win32" && existsSync(path)) {
1897
+ if (await socketInUse(path)) throw new Error(`charmd is already running (admin socket ${path} is in use).`);
1898
+ unlinkSync(path);
1899
+ }
1900
+ const server = createServer((socket) => handle(socket, actions));
1901
+ await new Promise((resolve, reject) => {
1902
+ server.once("error", (error) => reject(error.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`charmd is already running (admin socket ${path} is in use).`) : error));
1903
+ server.listen(path, resolve);
1904
+ });
1905
+ if (process.platform !== "win32") chmodSync(path, 384);
1906
+ return server;
1907
+ }
1908
+ //#endregion
1909
+ //#region ../charmd/src/auth/secrets.ts
1910
+ const SCRYPT = {
1911
+ N: 2 ** 15,
1912
+ r: 8,
1913
+ p: 1,
1914
+ keyLength: 32,
1915
+ saltBytes: 16
1916
+ };
1917
+ const SCRYPT_MAXMEM = 67108864;
1918
+ function scryptAsync(pin, salt, N, r, p) {
1919
+ return new Promise((resolve, reject) => {
1920
+ scrypt(pin, salt, SCRYPT.keyLength, {
1921
+ N,
1922
+ r,
1923
+ p,
1924
+ maxmem: SCRYPT_MAXMEM
1925
+ }, (error, key) => error ? reject(error) : resolve(key));
1926
+ });
1927
+ }
1928
+ function generateToken() {
1929
+ return randomBytes(32).toString("base64url");
1930
+ }
1931
+ function hashToken(token) {
1932
+ return createHash("sha256").update(token).digest("hex");
1933
+ }
1934
+ function generatePairCode() {
1935
+ return String(randomInt(0, 1e6)).padStart(6, "0");
1936
+ }
1937
+ async function hashPin(pin) {
1938
+ const salt = randomBytes(SCRYPT.saltBytes);
1939
+ const key = await scryptAsync(pin, salt, SCRYPT.N, SCRYPT.r, SCRYPT.p);
1940
+ return [
1941
+ "scrypt",
1942
+ SCRYPT.N,
1943
+ SCRYPT.r,
1944
+ SCRYPT.p,
1945
+ salt.toString("base64url"),
1946
+ key.toString("base64url")
1947
+ ].join("$");
1948
+ }
1949
+ async function verifyPin(pin, stored) {
1950
+ const [scheme, n, r, p, salt, key] = stored.split("$");
1951
+ if (scheme !== "scrypt" || !n || !r || !p || !salt || !key) return false;
1952
+ const expected = Buffer.from(key, "base64url");
1953
+ const actual = await scryptAsync(pin, Buffer.from(salt, "base64url"), Number(n), Number(r), Number(p));
1954
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
1955
+ }
1956
+ //#endregion
1957
+ //#region ../charmd/src/auth/pairing.ts
1958
+ var PairingRegistry = class {
1959
+ #options;
1960
+ #byCode = /* @__PURE__ */ new Map();
1961
+ constructor(options) {
1962
+ this.#options = {
1963
+ now: Date.now,
1964
+ generate: generatePairCode,
1965
+ ...options
1966
+ };
1967
+ }
1968
+ #prune() {
1969
+ const now = this.#options.now();
1970
+ for (const [code, pending] of this.#byCode) if (pending.expiresAt <= now) this.#byCode.delete(code);
1971
+ }
1972
+ issue(connectionId) {
1973
+ this.#prune();
1974
+ this.release(connectionId);
1975
+ if (this.#byCode.size >= this.#options.maxPending) throw new Error("Too many charms waiting to pair; try again shortly.");
1976
+ let code = this.#options.generate();
1977
+ while (this.#byCode.has(code)) code = this.#options.generate();
1978
+ this.#byCode.set(code, {
1979
+ code,
1980
+ connectionId,
1981
+ expiresAt: this.#options.now() + this.#options.ttlMs
1982
+ });
1983
+ return {
1984
+ code,
1985
+ expiresIn: Math.round(this.#options.ttlMs / 1e3)
1986
+ };
1987
+ }
1988
+ claim(code) {
1989
+ this.#prune();
1990
+ const pending = this.#byCode.get(code);
1991
+ if (!pending) return void 0;
1992
+ this.#byCode.delete(code);
1993
+ return pending.connectionId;
1994
+ }
1995
+ release(connectionId) {
1996
+ for (const [code, pending] of this.#byCode) if (pending.connectionId === connectionId) this.#byCode.delete(code);
1997
+ }
1998
+ };
1999
+ //#endregion
2000
+ //#region ../charmd/src/device/ota.ts
2001
+ function handleOta(res, websocketUrl) {
2002
+ res.writeHead(200, { "content-type": "application/json" });
2003
+ res.end(JSON.stringify({
2004
+ websocket: { url: websocketUrl },
2005
+ server_time: {
2006
+ timestamp: Date.now(),
2007
+ timezone_offset: 0
2008
+ }
2009
+ }));
2010
+ }
2011
+ function isOtaRequest(req) {
2012
+ const path = (req.url ?? "").split("?")[0];
2013
+ return (path === "/ota/" || path === "/ota") && (req.method === "GET" || req.method === "POST");
2014
+ }
2015
+ //#endregion
2016
+ //#region ../charmd/src/device/raw-data.ts
2017
+ function toBuffer(data) {
2018
+ if (Array.isArray(data)) return Buffer.concat(data);
2019
+ return Buffer.isBuffer(data) ? data : Buffer.from(data);
2020
+ }
2021
+ //#endregion
2022
+ //#region ../charmd/src/device/token.ts
2023
+ const TOKEN = /^[A-Za-z0-9_-]{1,256}$/;
2024
+ const SUBPROTOCOL = "opencharm";
2025
+ const TOKEN_PREFIX = `${SUBPROTOCOL}.token.`;
2026
+ function tokenFrom(req) {
2027
+ const header = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
2028
+ if (header !== void 0) return TOKEN.test(header) ? header : void 0;
2029
+ const token = (req.headers["sec-websocket-protocol"] ?? "").split(",").map((p) => p.trim()).find((p) => p.startsWith(TOKEN_PREFIX))?.slice(TOKEN_PREFIX.length);
2030
+ return token && TOKEN.test(token) ? token : void 0;
2031
+ }
2032
+ //#endregion
2033
+ //#region ../protocol/src/parse.ts
2034
+ const encoder = new TextEncoder();
2035
+ function fail(code, detail) {
2036
+ return {
2037
+ ok: false,
2038
+ error: {
2039
+ code,
2040
+ detail
2041
+ }
2042
+ };
2043
+ }
2044
+ function parseWith(raw, schemas) {
2045
+ if (encoder.encode(raw).byteLength > LIMITS.maxJsonBytes) return fail("too_large", `over ${LIMITS.maxJsonBytes} bytes`);
2046
+ let data;
2047
+ try {
2048
+ data = JSON.parse(raw);
2049
+ } catch {
2050
+ return fail("invalid_json", "not JSON");
2051
+ }
2052
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return fail("invalid_json", "not a JSON object");
2053
+ const { type, op } = data;
2054
+ if (typeof type !== "string") return fail("unknown_type", "missing type");
2055
+ const kind = messageKind({
2056
+ type,
2057
+ ...typeof op === "string" ? { op } : {}
2058
+ });
2059
+ const schema = schemas[kind];
2060
+ if (!schema) return type === "charm" && Object.keys(schemas).some((k) => k.startsWith("charm:")) ? fail("unknown_op", `unknown op ${kind}`) : fail("unknown_type", `unknown type ${type}`);
2061
+ const parsed = schema.safeParse(data);
2062
+ if (!parsed.success) return fail("invalid_fields", parsed.error.issues.map((i) => i.path.join(".") || i.message).join(", "));
2063
+ return {
2064
+ ok: true,
2065
+ message: parsed.data
2066
+ };
2067
+ }
2068
+ function parseClientMessage(raw) {
2069
+ return parseWith(raw, CLIENT_SCHEMAS);
2070
+ }
2071
+ //#endregion
2072
+ //#region ../charmd/src/device/session.ts
2073
+ const MAX_TRIES = 5;
2074
+ const ASK_TIMEOUT_MS = 3e4;
2075
+ const MAX_ASK_TEXT = 200;
2076
+ const MAX_ASK_LABEL = 12;
2077
+ const CLOSE_POLICY$1 = 1008;
2078
+ const CLOSE_TOO_BIG = 1009;
2079
+ const CLOSE_REVOKED = 4001;
2080
+ function newCharmId() {
2081
+ return `c_${randomBytes(6).toString("hex")}`;
2082
+ }
2083
+ var Session = class {
2084
+ connectionId;
2085
+ sessionId = randomBytes(8).toString("hex");
2086
+ #state = "awaiting_hello";
2087
+ #charmId;
2088
+ #queue = Promise.resolve();
2089
+ #turn;
2090
+ #asks = Promise.resolve();
2091
+ #pendingAsk;
2092
+ #deps;
2093
+ constructor(deps) {
2094
+ this.#deps = deps;
2095
+ this.connectionId = deps.connectionId;
2096
+ }
2097
+ get state() {
2098
+ return this.#state;
2099
+ }
2100
+ get charmId() {
2101
+ return this.#charmId;
2102
+ }
2103
+ #send(message) {
2104
+ if (this.#state !== "closed") this.#deps.outbound.send(message);
2105
+ }
2106
+ #endTurn() {
2107
+ this.#turn?.dispose();
2108
+ this.#turn = void 0;
2109
+ }
2110
+ #activeTurn() {
2111
+ if (!this.#turn && this.#deps.createTurn && this.#charmId) this.#turn = this.#deps.createTurn({
2112
+ sessionKey: `opencharm-${this.#charmId}`,
2113
+ send: (message) => this.#send(message),
2114
+ sendAudio: (packet) => {
2115
+ if (this.#state === "unlocked") this.#deps.outbound.sendAudio?.(packet);
2116
+ },
2117
+ ask: (text, options) => this.ask(text, options)
2118
+ });
2119
+ return this.#turn;
2120
+ }
2121
+ #close(code) {
2122
+ if (this.#state === "closed") return;
2123
+ this.#pendingAsk?.drop();
2124
+ this.#endTurn();
2125
+ this.#state = "closed";
2126
+ this.#deps.pairing.release(this.connectionId);
2127
+ this.#deps.outbound.close(code);
2128
+ }
2129
+ #charm() {
2130
+ return this.#charmId ? this.#deps.store.find(this.#charmId) : void 0;
2131
+ }
2132
+ #sendLocked() {
2133
+ const charm = this.#charm();
2134
+ if (charm?.blocked) this.#send({
2135
+ type: "charm",
2136
+ op: "locked",
2137
+ reason: "blocked"
2138
+ });
2139
+ else this.#send({
2140
+ type: "charm",
2141
+ op: "locked",
2142
+ reason: "boot",
2143
+ tries_left: MAX_TRIES - (charm?.failedTries ?? 0)
2144
+ });
2145
+ }
2146
+ #sendUnlocked() {
2147
+ const look = this.#deps.look?.();
2148
+ if (look) this.#send(look);
2149
+ this.#send({
2150
+ type: "charm",
2151
+ op: "unlocked"
2152
+ });
2153
+ }
2154
+ #issuePairCode() {
2155
+ try {
2156
+ const { code, expiresIn } = this.#deps.pairing.issue(this.connectionId);
2157
+ this.#send({
2158
+ type: "charm",
2159
+ op: "pair_code",
2160
+ code,
2161
+ expires_in: expiresIn
2162
+ });
2163
+ } catch {
2164
+ this.#close(CLOSE_POLICY$1);
2165
+ }
2166
+ }
2167
+ onText(raw) {
2168
+ this.#queue = this.#queue.then(() => this.#handleText(raw));
2169
+ return this.#queue;
2170
+ }
2171
+ async #handleText(raw) {
2172
+ if (this.#state === "closed") return;
2173
+ const parsed = parseClientMessage(raw);
2174
+ if (!parsed.ok) {
2175
+ this.#close(CLOSE_POLICY$1);
2176
+ return;
2177
+ }
2178
+ const message = parsed.message;
2179
+ if (this.#state === "awaiting_hello") {
2180
+ if (message.type !== "hello") {
2181
+ this.#close(CLOSE_POLICY$1);
2182
+ return;
2183
+ }
2184
+ this.#onHello();
2185
+ return;
2186
+ }
2187
+ await this.#onMessage(message);
2188
+ }
2189
+ #onHello() {
2190
+ this.#send({
2191
+ type: "hello",
2192
+ transport: "websocket",
2193
+ session_id: this.sessionId,
2194
+ audio_params: AUDIO_DOWN
2195
+ });
2196
+ const { token } = this.#deps;
2197
+ if (token === void 0) {
2198
+ this.#state = "unpaired";
2199
+ this.#issuePairCode();
2200
+ return;
2201
+ }
2202
+ const charm = this.#deps.store.findByTokenHash(hashToken(token));
2203
+ if (!charm) {
2204
+ this.#send({
2205
+ type: "charm",
2206
+ op: "revoked"
2207
+ });
2208
+ this.#close(CLOSE_REVOKED);
2209
+ return;
2210
+ }
2211
+ this.#charmId = charm.id;
2212
+ this.#state = "locked";
2213
+ this.#sendLocked();
2214
+ }
2215
+ async #onMessage(message) {
2216
+ if (message.type === "hello") return;
2217
+ if (this.#state === "unpaired") {
2218
+ this.#issuePairCode();
2219
+ return;
2220
+ }
2221
+ if (message.type === "charm" && message.op === "unlock") {
2222
+ await this.#onUnlock(message.pin);
2223
+ return;
2224
+ }
2225
+ if (this.#state !== "unlocked") {
2226
+ this.#sendLocked();
2227
+ return;
2228
+ }
2229
+ if (message.type === "charm" && message.op === "answer") {
2230
+ if (this.#pendingAsk?.id === message.id) this.#pendingAsk.settle(message.yes);
2231
+ return;
2232
+ }
2233
+ const turn = this.#activeTurn();
2234
+ if (!turn) return;
2235
+ if (message.type === "listen") {
2236
+ if (message.state === "start") turn.listenStart();
2237
+ else if (message.state === "stop") turn.listenStop();
2238
+ } else if (message.type === "abort") turn.abort();
2239
+ }
2240
+ async #onUnlock(pin) {
2241
+ if (this.#state === "unlocked") {
2242
+ this.#sendUnlocked();
2243
+ return;
2244
+ }
2245
+ const charm = this.#charm();
2246
+ if (!charm || charm.blocked) {
2247
+ this.#sendLocked();
2248
+ return;
2249
+ }
2250
+ if (await verifyPin(pin, charm.pinHash)) {
2251
+ this.#deps.store.put({
2252
+ ...charm,
2253
+ failedTries: 0
2254
+ });
2255
+ this.#state = "unlocked";
2256
+ this.#sendUnlocked();
2257
+ return;
2258
+ }
2259
+ const failedTries = charm.failedTries + 1;
2260
+ const blocked = failedTries >= MAX_TRIES;
2261
+ this.#deps.store.put({
2262
+ ...charm,
2263
+ failedTries,
2264
+ blocked
2265
+ });
2266
+ this.#send(blocked ? {
2267
+ type: "charm",
2268
+ op: "locked",
2269
+ reason: "blocked"
2270
+ } : {
2271
+ type: "charm",
2272
+ op: "locked",
2273
+ reason: "wrong_pin",
2274
+ tries_left: MAX_TRIES - failedTries
2275
+ });
2276
+ }
2277
+ onBinary(frame) {
2278
+ if (frame.byteLength > LIMITS.maxAudioFrameBytes) {
2279
+ this.#close(CLOSE_TOO_BIG);
2280
+ return Promise.resolve();
2281
+ }
2282
+ this.#queue = this.#queue.then(() => this.#handleBinary(frame));
2283
+ return this.#queue;
2284
+ }
2285
+ #handleBinary(frame) {
2286
+ if (this.#state === "closed") return;
2287
+ if (this.#state !== "unlocked") {
2288
+ if (this.#state === "locked") this.#sendLocked();
2289
+ return;
2290
+ }
2291
+ this.#activeTurn()?.audio(frame);
2292
+ }
2293
+ refreshPairCode() {
2294
+ if (this.#state === "unpaired") this.#issuePairCode();
2295
+ }
2296
+ async completePairing(name, pin) {
2297
+ if (this.#state !== "unpaired") throw new Error("This charm is not waiting to pair.");
2298
+ const token = generateToken();
2299
+ const record = {
2300
+ id: newCharmId(),
2301
+ name,
2302
+ tokenHash: hashToken(token),
2303
+ pinHash: await hashPin(pin),
2304
+ failedTries: 0,
2305
+ blocked: false,
2306
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2307
+ };
2308
+ this.#deps.store.put(record);
2309
+ this.#deps.pairing.release(this.connectionId);
2310
+ this.#charmId = record.id;
2311
+ this.#state = "locked";
2312
+ this.#send({
2313
+ type: "charm",
2314
+ op: "paired",
2315
+ token
2316
+ });
2317
+ this.#sendLocked();
2318
+ return token;
2319
+ }
2320
+ ask(text, options = {}) {
2321
+ const next = () => this.#askNow(text, options);
2322
+ const result = this.#asks.then(next, next);
2323
+ this.#asks = result;
2324
+ const { signal } = options;
2325
+ if (!signal) return result;
2326
+ const cancelled = new Promise((resolve) => {
2327
+ if (signal.aborted) resolve(false);
2328
+ signal.addEventListener("abort", () => resolve(false), { once: true });
2329
+ });
2330
+ return Promise.race([result, cancelled]);
2331
+ }
2332
+ #askNow(text, { yes, no, signal }) {
2333
+ if (this.#state !== "unlocked" || signal?.aborted) return Promise.resolve(false);
2334
+ const id = randomBytes(6).toString("hex");
2335
+ return new Promise((resolve) => {
2336
+ const finish = (answer, clearCharm) => {
2337
+ if (this.#pendingAsk?.id !== id) return;
2338
+ this.#pendingAsk = void 0;
2339
+ clearTimeout(timer);
2340
+ signal?.removeEventListener("abort", onAbort);
2341
+ if (clearCharm) this.#send({
2342
+ type: "charm",
2343
+ op: "ask_end",
2344
+ id
2345
+ });
2346
+ resolve(answer);
2347
+ };
2348
+ const onAbort = () => finish(false, true);
2349
+ const timer = setTimeout(() => finish(false, true), this.#deps.askTimeoutMs ?? ASK_TIMEOUT_MS);
2350
+ signal?.addEventListener("abort", onAbort, { once: true });
2351
+ this.#pendingAsk = {
2352
+ id,
2353
+ settle: (answer) => finish(answer, false),
2354
+ drop: () => finish(false, false)
2355
+ };
2356
+ this.#send({
2357
+ type: "charm",
2358
+ op: "ask",
2359
+ id,
2360
+ text: fitUtf8(text, MAX_ASK_TEXT),
2361
+ ...yes ? { yes: fitUtf8(yes, MAX_ASK_LABEL) } : {},
2362
+ ...no ? { no: fitUtf8(no, MAX_ASK_LABEL) } : {}
2363
+ });
2364
+ });
2365
+ }
2366
+ lockRemote() {
2367
+ if (this.#state !== "unlocked") return;
2368
+ this.#pendingAsk?.drop();
2369
+ this.#endTurn();
2370
+ this.#state = "locked";
2371
+ this.#send({
2372
+ type: "charm",
2373
+ op: "locked",
2374
+ reason: "remote"
2375
+ });
2376
+ }
2377
+ unlockByAdmin() {
2378
+ const charm = this.#charm();
2379
+ if (charm) this.#deps.store.put({
2380
+ ...charm,
2381
+ blocked: false,
2382
+ failedTries: 0
2383
+ });
2384
+ if (this.#state === "locked") {
2385
+ this.#state = "unlocked";
2386
+ this.#sendUnlocked();
2387
+ }
2388
+ }
2389
+ sendLook(look) {
2390
+ if (this.#state === "unlocked") this.#send(look);
2391
+ }
2392
+ devFace(state, text) {
2393
+ if (this.#state !== "unlocked") throw new Error("The charm must be unlocked first.");
2394
+ const line = text ? fitUtf8(text, MAX_ASK_TEXT) : void 0;
2395
+ if (this.#turn?.busy) {
2396
+ this.#turn.showFace(state, line);
2397
+ return;
2398
+ }
2399
+ this.#send({
2400
+ type: "charm",
2401
+ op: "face",
2402
+ state,
2403
+ ...line ? { text: line } : {}
2404
+ });
2405
+ }
2406
+ devSay(text) {
2407
+ if (this.#state !== "unlocked") throw new Error("The charm must be unlocked first.");
2408
+ const turn = this.#activeTurn();
2409
+ if (!turn) throw new Error("This charmd has no voice configured.");
2410
+ if (turn.busy) throw new Error("The charm is in the middle of a turn: put this in your reply instead.");
2411
+ turn.say(text).catch(() => void 0);
2412
+ }
2413
+ devAsk(text, options = {}) {
2414
+ if (this.#state !== "unlocked") return Promise.reject(/* @__PURE__ */ new Error("The charm must be unlocked first."));
2415
+ return this.#turn ? this.#turn.ask(text, options) : this.ask(text, options);
2416
+ }
2417
+ revoke() {
2418
+ this.#send({
2419
+ type: "charm",
2420
+ op: "revoked"
2421
+ });
2422
+ this.#close(CLOSE_REVOKED);
2423
+ }
2424
+ closed() {
2425
+ if (this.#state === "closed") return;
2426
+ this.#pendingAsk?.drop();
2427
+ this.#endTurn();
2428
+ this.#state = "closed";
2429
+ this.#deps.pairing.release(this.connectionId);
2430
+ }
2431
+ };
2432
+ //#endregion
2433
+ //#region ../charmd/src/store/state-store.ts
2434
+ const recordSchema = z.object({
2435
+ id: z.string().regex(/^c_[a-z0-9]{6,}$/),
2436
+ name: z.string().min(1).max(40),
2437
+ tokenHash: z.string().regex(/^[0-9a-f]{64}$/),
2438
+ pinHash: z.string().min(1),
2439
+ failedTries: z.number().int().min(0),
2440
+ blocked: z.boolean(),
2441
+ createdAt: z.string()
2442
+ });
2443
+ const stateSchema = z.object({
2444
+ version: z.literal(1),
2445
+ charms: z.array(recordSchema)
2446
+ });
2447
+ var StateStore = class StateStore {
2448
+ #path;
2449
+ #state;
2450
+ constructor(path) {
2451
+ this.#path = path;
2452
+ this.#state = StateStore.#read(path);
2453
+ }
2454
+ static #read(path) {
2455
+ if (!existsSync(path)) return {
2456
+ version: 1,
2457
+ charms: []
2458
+ };
2459
+ try {
2460
+ return stateSchema.parse(JSON.parse(readFileSync(path, "utf8")));
2461
+ } catch (error) {
2462
+ throw new Error(`The charmd state file ${path} is unreadable (${error instanceof Error ? error.message : String(error)}). Fix or move it; charmd won't overwrite it.`);
2463
+ }
2464
+ }
2465
+ #write() {
2466
+ mkdirSync(dirname(this.#path), {
2467
+ recursive: true,
2468
+ mode: 448
2469
+ });
2470
+ const temp = `${this.#path}.${process.pid}.tmp`;
2471
+ writeFileSync(temp, `${JSON.stringify(this.#state, null, 2)}\n`, { mode: 384 });
2472
+ renameSync(temp, this.#path);
2473
+ chmodSync(this.#path, 384);
2474
+ }
2475
+ list() {
2476
+ return this.#state.charms.map((charm) => ({ ...charm }));
2477
+ }
2478
+ find(idOrName) {
2479
+ return this.#state.charms.find((charm) => charm.id === idOrName || charm.name === idOrName);
2480
+ }
2481
+ findByTokenHash(tokenHash) {
2482
+ return this.#state.charms.find((charm) => charm.tokenHash === tokenHash);
2483
+ }
2484
+ put(record) {
2485
+ const next = recordSchema.parse(record);
2486
+ this.#state.charms = [...this.#state.charms.filter((charm) => charm.id !== next.id), next];
2487
+ this.#write();
2488
+ }
2489
+ remove(id) {
2490
+ this.#state.charms = this.#state.charms.filter((charm) => charm.id !== id);
2491
+ this.#write();
2492
+ }
2493
+ };
2494
+ //#endregion
2495
+ //#region ../charmd/src/audio/ogg-opus.ts
2496
+ const FIRST_PAGE = 2;
2497
+ const LAST_PAGE = 4;
2498
+ const MAX_SEGMENTS = 255;
2499
+ const DEFAULT_PRE_SKIP = 312;
2500
+ const VENDOR = "opencharm";
2501
+ const SILK = [
2502
+ 480,
2503
+ 960,
2504
+ 1920,
2505
+ 2880
2506
+ ];
2507
+ const HYBRID = [480, 960];
2508
+ const CELT = [
2509
+ 120,
2510
+ 240,
2511
+ 480,
2512
+ 960
2513
+ ];
2514
+ const CRC_TABLE = (() => {
2515
+ const table = /* @__PURE__ */ new Uint32Array(256);
2516
+ for (let i = 0; i < 256; i++) {
2517
+ let r = i << 24;
2518
+ for (let j = 0; j < 8; j++) r = r & 2147483648 ? r << 1 ^ 79764919 : r << 1;
2519
+ table[i] = r >>> 0;
2520
+ }
2521
+ return table;
2522
+ })();
2523
+ function crc32(data) {
2524
+ let crc = 0;
2525
+ for (const byte of data) crc = (crc << 8 ^ (CRC_TABLE[(crc >>> 24 ^ byte) & 255] ?? 0)) >>> 0;
2526
+ return crc;
2527
+ }
2528
+ function lacing(length) {
2529
+ const segments = new Array(Math.floor(length / 255)).fill(255);
2530
+ segments.push(length % 255);
2531
+ return segments;
2532
+ }
2533
+ function page(flags, granule, serial, sequence, segments, body) {
2534
+ const header = Buffer.alloc(27 + segments.length);
2535
+ header.write("OggS", 0, "ascii");
2536
+ header.writeUInt8(0, 4);
2537
+ header.writeUInt8(flags, 5);
2538
+ header.writeBigUInt64LE(granule, 6);
2539
+ header.writeUInt32LE(serial, 14);
2540
+ header.writeUInt32LE(sequence, 18);
2541
+ header.writeUInt8(segments.length, 26);
2542
+ Buffer.from(segments).copy(header, 27);
2543
+ const out = Buffer.concat([header, body]);
2544
+ out.writeUInt32LE(crc32(out), 22);
2545
+ return out;
2546
+ }
2547
+ function opusPacketSamples48k(packet) {
2548
+ const toc = packet[0] ?? 0;
2549
+ const config = toc >> 3;
2550
+ const frame = config < 12 ? SILK[config % 4] ?? 0 : config < 16 ? HYBRID[config % 2] ?? 0 : CELT[config % 4] ?? 0;
2551
+ const code = toc & 3;
2552
+ return frame * (code === 0 ? 1 : code === 3 ? (packet[1] ?? 0) & 63 : 2);
2553
+ }
2554
+ function writeOggOpus(packets, options) {
2555
+ const serial = options.serial ?? 1868785778;
2556
+ const head = Buffer.alloc(19);
2557
+ head.write("OpusHead", 0, "ascii");
2558
+ head.writeUInt8(1, 8);
2559
+ head.writeUInt8(options.channels ?? 1, 9);
2560
+ head.writeUInt16LE(DEFAULT_PRE_SKIP, 10);
2561
+ head.writeUInt32LE(options.inputSampleRate, 12);
2562
+ head.writeInt16LE(0, 16);
2563
+ head.writeUInt8(0, 18);
2564
+ const vendor = Buffer.from(VENDOR, "utf8");
2565
+ const tags = Buffer.alloc(12 + vendor.length + 4);
2566
+ tags.write("OpusTags", 0, "ascii");
2567
+ tags.writeUInt32LE(vendor.length, 8);
2568
+ vendor.copy(tags, 12);
2569
+ tags.writeUInt32LE(0, 12 + vendor.length);
2570
+ const pages = [page(FIRST_PAGE, 0n, serial, 0, lacing(head.length), head), page(0, 0n, serial, 1, lacing(tags.length), tags)];
2571
+ let sequence = 2;
2572
+ let granule = 0n;
2573
+ let segments = [];
2574
+ let bodies = [];
2575
+ const flush = (last) => {
2576
+ pages.push(page(last ? LAST_PAGE : 0, granule, serial, sequence++, segments, Buffer.concat(bodies)));
2577
+ segments = [];
2578
+ bodies = [];
2579
+ };
2580
+ packets.forEach((packet, index) => {
2581
+ const laced = lacing(packet.length);
2582
+ if (laced.length > MAX_SEGMENTS) throw new Error("Opus packet too large for one Ogg page");
2583
+ if (segments.length + laced.length > MAX_SEGMENTS) flush(false);
2584
+ segments.push(...laced);
2585
+ bodies.push(packet);
2586
+ granule += BigInt(opusPacketSamples48k(packet));
2587
+ if (index === packets.length - 1) flush(true);
2588
+ });
2589
+ if (packets.length === 0) flush(true);
2590
+ return Buffer.concat(pages);
2591
+ }
2592
+ function readOggOpus(data) {
2593
+ const packets = [];
2594
+ let pending = [];
2595
+ let offset = 0;
2596
+ while (offset < data.length) {
2597
+ if (data.toString("ascii", offset, offset + 4) !== "OggS") throw new Error("Not an Ogg stream (missing OggS page)");
2598
+ const count = data.readUInt8(offset + 26);
2599
+ const table = data.subarray(offset + 27, offset + 27 + count);
2600
+ let cursor = offset + 27 + count;
2601
+ for (const size of table) {
2602
+ pending.push(data.subarray(cursor, cursor + size));
2603
+ cursor += size;
2604
+ if (size < 255) {
2605
+ packets.push(Buffer.concat(pending));
2606
+ pending = [];
2607
+ }
2608
+ }
2609
+ offset = cursor;
2610
+ }
2611
+ const [headPacket, tagsPacket, ...audio] = packets;
2612
+ if (!headPacket || headPacket.toString("ascii", 0, 8) !== "OpusHead") throw new Error("Not an Ogg Opus stream (no OpusHead)");
2613
+ if (!tagsPacket || tagsPacket.toString("ascii", 0, 8) !== "OpusTags") throw new Error("Not an Ogg Opus stream (no OpusTags)");
2614
+ return {
2615
+ head: {
2616
+ channels: headPacket.readUInt8(9),
2617
+ preSkip: headPacket.readUInt16LE(10),
2618
+ inputSampleRate: headPacket.readUInt32LE(12)
2619
+ },
2620
+ packets: audio
2621
+ };
2622
+ }
2623
+ //#endregion
2624
+ //#region ../charmd/src/turn/sentences.ts
2625
+ const MIN_CHARS = 10;
2626
+ const ABBREVIATIONS = /* @__PURE__ */ new Set([
2627
+ "e.g.",
2628
+ "i.e.",
2629
+ "dr.",
2630
+ "mr.",
2631
+ "mrs.",
2632
+ "ms.",
2633
+ "st.",
2634
+ "vs.",
2635
+ "no."
2636
+ ]);
2637
+ const BOUNDARY = /[.!?…]+(?=\s)|\n+/g;
2638
+ var SentenceSplitter = class {
2639
+ #buffer = "";
2640
+ #pending = "";
2641
+ #take(piece) {
2642
+ const text = piece.replace(/\s+/g, " ").trim();
2643
+ if (!text) return [];
2644
+ this.#pending = this.#pending ? `${this.#pending} ${text}` : text;
2645
+ if (this.#pending.length < MIN_CHARS) return [];
2646
+ const out = this.#pending;
2647
+ this.#pending = "";
2648
+ return [out];
2649
+ }
2650
+ push(chunk) {
2651
+ this.#buffer += chunk;
2652
+ const out = [];
2653
+ let start = 0;
2654
+ for (const match of this.#buffer.matchAll(BOUNDARY)) {
2655
+ const end = match.index + match[0].length;
2656
+ const lastWord = this.#buffer.slice(start, end).trim().split(/\s+/).at(-1)?.toLowerCase() ?? "";
2657
+ if (match[0].startsWith(".") && ABBREVIATIONS.has(lastWord)) continue;
2658
+ out.push(...this.#take(this.#buffer.slice(start, end)));
2659
+ start = end;
2660
+ }
2661
+ this.#buffer = this.#buffer.slice(start);
2662
+ return out;
2663
+ }
2664
+ flush() {
2665
+ const rest = this.#take(this.#buffer);
2666
+ this.#buffer = "";
2667
+ const tail = this.#pending;
2668
+ this.#pending = "";
2669
+ return tail ? [...rest, tail] : rest;
2670
+ }
2671
+ };
2672
+ function cleanForSpeech(text) {
2673
+ return text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/^\s*(?:#{1,6}|[-*+]|\d+\.)\s+/gm, "").replace(/[*_`~#>]/g, "").replace(/\s+/g, " ").trim();
2674
+ }
2675
+ //#endregion
2676
+ //#region ../charmd/src/turn/turn.ts
2677
+ const MAX_FRAMES = 1e3;
2678
+ const AGENT_LABELS = { ...Object.fromEntries(Object.entries(ACP_AGENTS).map(([name, agent]) => [name, agent.label])) };
2679
+ var TurnTimeout = class extends Error {};
2680
+ var StageError = class extends Error {
2681
+ stage;
2682
+ constructor(stage, cause) {
2683
+ super(cause instanceof Error ? cause.message : String(cause));
2684
+ this.stage = stage;
2685
+ }
2686
+ };
2687
+ function sleepMs(ms) {
2688
+ return new Promise((resolve) => setTimeout(resolve, ms));
2689
+ }
2690
+ async function inStage(stage, work) {
2691
+ try {
2692
+ return await work();
2693
+ } catch (error) {
2694
+ throw error instanceof TurnTimeout ? error : new StageError(stage, error);
2695
+ }
2696
+ }
2697
+ var TurnController = class {
2698
+ #state = "idle";
2699
+ #frames = [];
2700
+ #generation = 0;
2701
+ #controller;
2702
+ #speaking = false;
2703
+ #asking;
2704
+ #endFace;
2705
+ #deps;
2706
+ constructor(deps) {
2707
+ this.#deps = deps;
2708
+ }
2709
+ get state() {
2710
+ return this.#state;
2711
+ }
2712
+ #face(state, text) {
2713
+ this.#deps.send({
2714
+ type: "charm",
2715
+ op: "face",
2716
+ state,
2717
+ ...text ? { text } : {}
2718
+ });
2719
+ }
2720
+ get busy() {
2721
+ return this.#state !== "idle";
2722
+ }
2723
+ showFace(state, text) {
2724
+ this.#face(state, text);
2725
+ if (this.busy) this.#endFace = {
2726
+ state,
2727
+ ...text ? { text } : {}
2728
+ };
2729
+ }
2730
+ ask(text, options = {}) {
2731
+ if (this.#asking) return this.#asking(text, options);
2732
+ return this.#deps.ask ? this.#deps.ask(text, options) : Promise.resolve(false);
2733
+ }
2734
+ #cancel() {
2735
+ this.#endFace = void 0;
2736
+ this.#generation += 1;
2737
+ this.#controller?.abort();
2738
+ this.#controller = void 0;
2739
+ if (this.#speaking) this.#deps.send({
2740
+ type: "tts",
2741
+ state: "stop"
2742
+ });
2743
+ this.#speaking = false;
2744
+ }
2745
+ listenStart() {
2746
+ if (this.#state === "thinking" || this.#state === "speaking") this.#cancel();
2747
+ this.#state = "listening";
2748
+ this.#frames = [];
2749
+ this.#face("listening");
2750
+ this.#deps.agent.warm?.(this.#deps.sessionKey);
2751
+ }
2752
+ audio(frame) {
2753
+ if (this.#state === "listening" && this.#frames.length < MAX_FRAMES) this.#frames.push(frame);
2754
+ }
2755
+ abort() {
2756
+ if (this.#state === "idle") return;
2757
+ this.#cancel();
2758
+ this.#state = "idle";
2759
+ this.#face("idle");
2760
+ }
2761
+ listenStop() {
2762
+ if (this.#state !== "listening") return Promise.resolve();
2763
+ const frames = this.#frames;
2764
+ this.#frames = [];
2765
+ if (frames.length === 0) {
2766
+ this.#state = "idle";
2767
+ this.#face("idle");
2768
+ return Promise.resolve();
2769
+ }
2770
+ return this.#run(frames);
2771
+ }
2772
+ async #run(frames) {
2773
+ const generation = ++this.#generation;
2774
+ const live = () => this.#generation === generation;
2775
+ const controller = new AbortController();
2776
+ this.#controller = controller;
2777
+ const { signal } = controller;
2778
+ const now = this.#deps.now ?? Date.now;
2779
+ const started = now();
2780
+ const timings = {};
2781
+ let heard = "";
2782
+ let said = "";
2783
+ let outcome = "done";
2784
+ const startClock = () => setTimeout(() => controller.abort(new TurnTimeout()), this.#deps.timeoutMs);
2785
+ let timer = startClock();
2786
+ const timeoutGuard = new Promise((_, reject) => signal.addEventListener("abort", () => {
2787
+ if (signal.reason instanceof TurnTimeout) reject(signal.reason);
2788
+ }));
2789
+ timeoutGuard.catch(() => void 0);
2790
+ this.#state = "thinking";
2791
+ this.#face("thinking");
2792
+ try {
2793
+ const ogg = writeOggOpus(frames, { inputSampleRate: 16e3 });
2794
+ heard = await Promise.race([inStage("stt", () => this.#deps.voice.transcribe(ogg, signal)), timeoutGuard]);
2795
+ timings.sttMs = now() - started;
2796
+ if (!live()) return;
2797
+ this.#deps.send({
2798
+ type: "stt",
2799
+ text: heard
2800
+ });
2801
+ if (!heard) {
2802
+ outcome = "nothing-heard";
2803
+ this.#state = "idle";
2804
+ this.#face("idle");
2805
+ return;
2806
+ }
2807
+ const ask = this.#deps.ask;
2808
+ const label = AGENT_LABELS[this.#deps.agent.name] ?? "Your agent";
2809
+ if (ask) this.#asking = async (text, options) => {
2810
+ clearTimeout(timer);
2811
+ try {
2812
+ return await ask(text, {
2813
+ ...options,
2814
+ signal
2815
+ });
2816
+ } finally {
2817
+ if (!signal.aborted) timer = startClock();
2818
+ }
2819
+ };
2820
+ const reply = this.#deps.agent.reply({
2821
+ sessionKey: this.#deps.sessionKey,
2822
+ text: heard,
2823
+ signal,
2824
+ ...ask ? { ask: (action) => this.ask(`${label} wants to ${action}.`) } : {}
2825
+ });
2826
+ said = await this.#speak(reply, signal, live, timings, started, timeoutGuard);
2827
+ if (!live()) return;
2828
+ if (this.#speaking) this.#deps.send({
2829
+ type: "tts",
2830
+ state: "stop"
2831
+ });
2832
+ this.#speaking = false;
2833
+ this.#state = "idle";
2834
+ const end = this.#endFace ?? { state: "idle" };
2835
+ this.#endFace = void 0;
2836
+ this.#face(end.state, end.text);
2837
+ } catch (error) {
2838
+ outcome = error instanceof TurnTimeout ? "timeout" : error instanceof StageError ? `${error.stage}-error` : "error";
2839
+ if (!live()) return;
2840
+ if (this.#speaking) this.#deps.send({
2841
+ type: "tts",
2842
+ state: "stop"
2843
+ });
2844
+ this.#speaking = false;
2845
+ this.#state = "idle";
2846
+ this.#face("failed", this.#failureLine(error));
2847
+ } finally {
2848
+ clearTimeout(timer);
2849
+ if (live()) {
2850
+ this.#controller = void 0;
2851
+ this.#asking = void 0;
2852
+ this.#endFace = void 0;
2853
+ }
2854
+ this.#deps.log?.({
2855
+ event: "turn",
2856
+ session: this.#deps.sessionKey,
2857
+ outcome: live() ? outcome : "cancelled",
2858
+ ...timings,
2859
+ totalMs: now() - started,
2860
+ ...this.#deps.logTranscripts ? {
2861
+ heard,
2862
+ said: said.trim()
2863
+ } : {}
2864
+ });
2865
+ }
2866
+ }
2867
+ async #speak(source, signal, live, timings, started, timeoutGuard) {
2868
+ const now = this.#deps.now ?? Date.now;
2869
+ const queue = [];
2870
+ let wake = () => void 0;
2871
+ let sourceDone = false;
2872
+ let said = "";
2873
+ const enqueue = (sentence) => {
2874
+ const text = cleanForSpeech(sentence);
2875
+ if (!text) return;
2876
+ said += `${text} `;
2877
+ const pending = inStage("tts", () => this.#deps.voice.synthesize(text, signal)).then((audio) => ({
2878
+ text,
2879
+ ogg: audio
2880
+ }));
2881
+ pending.catch(() => void 0);
2882
+ queue.push(pending);
2883
+ wake();
2884
+ };
2885
+ const produce = (async () => {
2886
+ const splitter = new SentenceSplitter();
2887
+ await inStage("agent", async () => {
2888
+ for await (const chunk of source) {
2889
+ if (!live()) return;
2890
+ for (const sentence of splitter.push(chunk)) enqueue(sentence);
2891
+ }
2892
+ });
2893
+ for (const sentence of splitter.flush()) enqueue(sentence);
2894
+ })().finally(() => {
2895
+ sourceDone = true;
2896
+ wake();
2897
+ });
2898
+ produce.catch(() => void 0);
2899
+ const play = async () => {
2900
+ let index = 0;
2901
+ for (;;) {
2902
+ if (index >= queue.length) {
2903
+ if (sourceDone) return;
2904
+ await new Promise((resolve) => wake = resolve);
2905
+ continue;
2906
+ }
2907
+ const { text, ogg } = await Promise.race([queue[index++], timeoutGuard]);
2908
+ if (!live()) return;
2909
+ if (!this.#speaking) {
2910
+ this.#speaking = true;
2911
+ this.#state = "speaking";
2912
+ timings.firstAudioMs = now() - started;
2913
+ this.#deps.send({
2914
+ type: "tts",
2915
+ state: "start"
2916
+ });
2917
+ }
2918
+ this.#deps.send({
2919
+ type: "tts",
2920
+ state: "sentence_start",
2921
+ text
2922
+ });
2923
+ await this.#sendPaced(readOggOpus(ogg).packets, live);
2924
+ }
2925
+ };
2926
+ await Promise.all([Promise.race([produce, timeoutGuard]), play()]);
2927
+ return said.trim();
2928
+ }
2929
+ async say(text) {
2930
+ if (this.busy) throw new Error("The charm is in the middle of a turn: put this in your reply instead.");
2931
+ const generation = ++this.#generation;
2932
+ const live = () => this.#generation === generation;
2933
+ const controller = new AbortController();
2934
+ this.#controller = controller;
2935
+ const timer = setTimeout(() => controller.abort(new TurnTimeout()), this.#deps.timeoutMs);
2936
+ const timeoutGuard = new Promise((_, reject) => controller.signal.addEventListener("abort", () => {
2937
+ if (controller.signal.reason instanceof TurnTimeout) reject(controller.signal.reason);
2938
+ }));
2939
+ timeoutGuard.catch(() => void 0);
2940
+ async function* once() {
2941
+ yield await Promise.resolve(text);
2942
+ }
2943
+ try {
2944
+ await this.#speak(once(), controller.signal, live, {}, Date.now(), timeoutGuard);
2945
+ if (!live()) return;
2946
+ if (this.#speaking) this.#deps.send({
2947
+ type: "tts",
2948
+ state: "stop"
2949
+ });
2950
+ this.#speaking = false;
2951
+ this.#state = "idle";
2952
+ this.#face("idle");
2953
+ } catch (error) {
2954
+ if (!live()) return;
2955
+ if (this.#speaking) this.#deps.send({
2956
+ type: "tts",
2957
+ state: "stop"
2958
+ });
2959
+ this.#speaking = false;
2960
+ this.#state = "idle";
2961
+ this.#face("failed", this.#failureLine(error));
2962
+ } finally {
2963
+ clearTimeout(timer);
2964
+ }
2965
+ }
2966
+ #failureLine(error) {
2967
+ if (error instanceof TurnTimeout) return "That took too long.";
2968
+ if (error instanceof StageError && error.stage === "stt") return "I couldn't hear that.";
2969
+ if (error instanceof StageError && error.stage === "tts") return "I couldn't speak that.";
2970
+ return `Can't reach ${AGENT_LABELS[this.#deps.agent.name] ?? "your agent"}`;
2971
+ }
2972
+ async #sendPaced(packets, live) {
2973
+ const sleep = this.#deps.sleep ?? sleepMs;
2974
+ for (const [index, packet] of packets.entries()) {
2975
+ if (!live()) return;
2976
+ this.#deps.sendAudio(packet);
2977
+ if (index >= 2) await sleep(opusPacketSamples48k(packet) / 48);
2978
+ }
2979
+ }
2980
+ dispose() {
2981
+ this.#cancel();
2982
+ this.#state = "idle";
2983
+ }
2984
+ };
2985
+ //#endregion
2986
+ //#region ../charmd/src/agent/acp.ts
2987
+ const AUTH_REQUIRED = -32e3;
2988
+ const NO_ACCESS = {
2989
+ fs: {
2990
+ readTextFile: false,
2991
+ writeTextFile: false
2992
+ },
2993
+ terminal: false
2994
+ };
2995
+ function queue() {
2996
+ const items = [];
2997
+ let wake;
2998
+ return {
2999
+ push(item) {
3000
+ items.push(item);
3001
+ wake?.();
3002
+ },
3003
+ async next() {
3004
+ while (items.length === 0) await new Promise((resolve) => wake = resolve);
3005
+ wake = void 0;
3006
+ return items.shift();
3007
+ }
3008
+ };
3009
+ }
3010
+ function realFolder(path, what) {
3011
+ try {
3012
+ return realpathSync(path);
3013
+ } catch {
3014
+ throw new Error(`${what} ${path} not found`);
3015
+ }
3016
+ }
3017
+ function describeAction(title, cwd) {
3018
+ const mcp = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(title.trim());
3019
+ if (mcp) return `use the ${mcp[1]} tool ${mcp[2]}`;
3020
+ const text = title.trim().split(`${cwd}/`).join("").split(`${dirname(cwd)}/`).join("../").split(homedir()).join("~");
3021
+ const action = text.charAt(0).toLowerCase() + text.slice(1);
3022
+ return action.length > 120 ? `${action.slice(0, 119)}…` : action;
3023
+ }
3024
+ function friendly(error, program) {
3025
+ if (error instanceof acp.RequestError) {
3026
+ if (error.code === AUTH_REQUIRED) return /* @__PURE__ */ new Error(`${program} needs you to log in first: run it once in a terminal and log in`);
3027
+ const details = error.data?.details;
3028
+ if (typeof details === "string") return new Error(details);
3029
+ }
3030
+ return error instanceof Error ? error : new Error(String(error));
3031
+ }
3032
+ function createAcpAgent(options) {
3033
+ const [program = "", ...args] = options.command;
3034
+ const log = options.log ?? (() => void 0);
3035
+ const sessions = /* @__PURE__ */ new Map();
3036
+ const listeners = /* @__PURE__ */ new Map();
3037
+ const askers = /* @__PURE__ */ new Map();
3038
+ let starting;
3039
+ let running;
3040
+ let child;
3041
+ const home = () => realFolder(options.cwd, "the workspace");
3042
+ async function launch() {
3043
+ const proc = spawn(program, args, {
3044
+ cwd: home(),
3045
+ env: {
3046
+ ...process.env,
3047
+ ...options.env
3048
+ },
3049
+ shell: process.platform === "win32"
3050
+ });
3051
+ child = proc;
3052
+ let stderr = "";
3053
+ let failure;
3054
+ proc.stderr.on("data", (chunk) => stderr = (stderr + chunk.toString()).slice(-400));
3055
+ proc.stdin.on("error", () => void 0);
3056
+ const connection = acp.client({ name: "charmd" }).onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
3057
+ const { options: offered, toolCall, sessionId } = ctx.params;
3058
+ const title = toolCall.title ?? "use a tool";
3059
+ const allow = offered.find((o) => o.kind === "allow_once");
3060
+ const reject = offered.find((o) => o.kind === "reject_once");
3061
+ const asker = askers.get(sessionId);
3062
+ if (!asker) log(`refused "${title}": nobody can answer (no turn running)`);
3063
+ else if (!allow) log(`refused "${title}": the agent offered no "allow once"`);
3064
+ if ((asker && allow ? await asker.ask(describeAction(title, home())) : false) && allow) return { outcome: {
3065
+ outcome: "selected",
3066
+ optionId: allow.optionId
3067
+ } };
3068
+ if (asker?.signal.aborted) return { outcome: { outcome: "cancelled" } };
3069
+ return reject ? { outcome: {
3070
+ outcome: "selected",
3071
+ optionId: reject.optionId
3072
+ } } : { outcome: { outcome: "cancelled" } };
3073
+ }).onNotification(acp.methods.client.session.update, (ctx) => listeners.get(ctx.params.sessionId)?.(ctx.params.update)).connect(acp.ndJsonStream(Writable.toWeb(proc.stdin), Readable.toWeb(proc.stdout)));
3074
+ const stopped = new Promise((_, reject) => {
3075
+ proc.on("error", (error) => {
3076
+ failure = error.code === "ENOENT" ? /* @__PURE__ */ new Error(`${program} not found: install it, or fix "command" in the config`) : error;
3077
+ });
3078
+ proc.on("close", (code) => {
3079
+ failure ??= /* @__PURE__ */ new Error(`the agent stopped (exit ${code ?? "?"})${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
3080
+ connection.close(failure);
3081
+ if (running?.child === proc) running = void 0;
3082
+ if (child === proc) {
3083
+ starting = void 0;
3084
+ sessions.clear();
3085
+ }
3086
+ for (const listener of listeners.values()) listener({ sessionUpdate: "__stopped" });
3087
+ reject(failure);
3088
+ });
3089
+ });
3090
+ stopped.catch(() => void 0);
3091
+ const explain = async (error) => {
3092
+ await Promise.race([stopped.catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 500))]);
3093
+ return failure ?? friendly(error, program);
3094
+ };
3095
+ const ready = connection.agent.request(acp.methods.agent.initialize, {
3096
+ protocolVersion: acp.PROTOCOL_VERSION,
3097
+ clientCapabilities: NO_ACCESS
3098
+ }).then((init) => {
3099
+ const agent = {
3100
+ child: proc,
3101
+ context: connection.agent,
3102
+ canAttach: Boolean(init.agentCapabilities?.sessionCapabilities?.additionalDirectories),
3103
+ failure: () => failure ?? /* @__PURE__ */ new Error("the agent stopped"),
3104
+ explain
3105
+ };
3106
+ running = agent;
3107
+ return agent;
3108
+ }).catch(async (error) => {
3109
+ proc.kill();
3110
+ throw await explain(error);
3111
+ });
3112
+ return Promise.race([ready, stopped]);
3113
+ }
3114
+ function start() {
3115
+ starting ??= launch();
3116
+ return starting;
3117
+ }
3118
+ function session(sessionKey) {
3119
+ let id = sessions.get(sessionKey);
3120
+ if (!id) {
3121
+ id = start().then(async (agent) => {
3122
+ if (options.projects?.length && !agent.canAttach) throw new Error(`${program} can't open extra folders: remove "projects" from the config`);
3123
+ try {
3124
+ const created = await agent.context.request(acp.methods.agent.session.new, {
3125
+ cwd: home(),
3126
+ mcpServers: options.mcpServers ?? [],
3127
+ ...options.sessionMeta ? { _meta: options.sessionMeta } : {},
3128
+ ...options.projects?.length ? { additionalDirectories: options.projects.map((path) => realFolder(path, "the project folder")) } : {}
3129
+ });
3130
+ const mode = options.mode;
3131
+ if (mode && created.modes?.currentModeId !== mode) {
3132
+ const offered = created.modes?.availableModes.map((m) => m.id);
3133
+ if (!offered?.includes(mode)) throw new Error(`${program} has no mode "${mode}" (it offers ${offered?.join(", ") || "none"})`);
3134
+ await agent.context.request(acp.methods.agent.session.setMode, {
3135
+ sessionId: created.sessionId,
3136
+ modeId: mode
3137
+ });
3138
+ }
3139
+ return {
3140
+ agent,
3141
+ sessionId: created.sessionId
3142
+ };
3143
+ } catch (error) {
3144
+ throw await agent.explain(error);
3145
+ }
3146
+ });
3147
+ const pending = id;
3148
+ pending.catch(() => {
3149
+ if (sessions.get(sessionKey) === pending) sessions.delete(sessionKey);
3150
+ });
3151
+ sessions.set(sessionKey, pending);
3152
+ }
3153
+ return id;
3154
+ }
3155
+ return {
3156
+ name: options.name ?? "acp",
3157
+ warm: (sessionKey) => void session(sessionKey).catch((error) => log(`agent not ready: ${error.message}`)),
3158
+ dispose: () => child?.kill(),
3159
+ async *reply({ sessionKey, text, signal, ask }) {
3160
+ const { agent, sessionId } = await session(sessionKey);
3161
+ if (signal.aborted) return;
3162
+ const items = queue();
3163
+ let wroteText = false;
3164
+ let breakLine = false;
3165
+ const own = (update) => {
3166
+ if (signal.aborted) return;
3167
+ if (update.sessionUpdate === "__stopped") items.push({ error: agent.failure() });
3168
+ else if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
3169
+ if (breakLine) items.push({ text: "\n" });
3170
+ breakLine = false;
3171
+ wroteText = true;
3172
+ items.push({ text: update.content.text });
3173
+ } else if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") breakLine = wroteText;
3174
+ };
3175
+ listeners.set(sessionId, own);
3176
+ const asker = ask ? {
3177
+ ask,
3178
+ signal
3179
+ } : void 0;
3180
+ if (asker) askers.set(sessionId, asker);
3181
+ const onAbort = () => {
3182
+ items.push({ end: true });
3183
+ agent.context.notify(acp.methods.agent.session.cancel, { sessionId }).catch(() => void 0);
3184
+ };
3185
+ signal.addEventListener("abort", onAbort, { once: true });
3186
+ agent.context.request(acp.methods.agent.session.prompt, {
3187
+ sessionId,
3188
+ prompt: [{
3189
+ type: "text",
3190
+ text
3191
+ }]
3192
+ }).then(() => items.push({ end: true }), async (error) => items.push({ error: await agent.explain(error) }));
3193
+ try {
3194
+ for (;;) {
3195
+ const item = await items.next();
3196
+ if (signal.aborted) return;
3197
+ if ("end" in item) return;
3198
+ if ("error" in item) throw item.error;
3199
+ yield item.text;
3200
+ }
3201
+ } finally {
3202
+ signal.removeEventListener("abort", onAbort);
3203
+ if (listeners.get(sessionId) === own) listeners.delete(sessionId);
3204
+ if (asker && askers.get(sessionId) === asker) askers.delete(sessionId);
3205
+ }
3206
+ }
3207
+ };
3208
+ }
3209
+ //#endregion
3210
+ //#region ../charmd/src/agent/fake.ts
3211
+ function createFakeAgent(options = {}) {
3212
+ return {
3213
+ name: "fake",
3214
+ async *reply({ text, signal }) {
3215
+ if (options.fail) throw new Error("The fake agent was told to fail");
3216
+ const answer = options.reply ? options.reply(text) : `You said: ${text}.`;
3217
+ for (const word of answer.split(/(?<= )/)) {
3218
+ if (signal.aborted) return;
3219
+ if (options.delayMs) await new Promise((r) => setTimeout(r, options.delayMs));
3220
+ yield word;
3221
+ }
3222
+ }
3223
+ };
3224
+ }
3225
+ //#endregion
3226
+ //#region ../charmd/src/agent/sse.ts
3227
+ async function* parseSse(body) {
3228
+ const decoder = new TextDecoder();
3229
+ let buffer = "";
3230
+ let event;
3231
+ let data = [];
3232
+ const reader = body.getReader();
3233
+ try {
3234
+ for (;;) {
3235
+ const { done, value } = await reader.read();
3236
+ buffer += done ? "\n\n" : decoder.decode(value, { stream: true });
3237
+ let newline = buffer.search(/\r?\n/);
3238
+ while (newline >= 0) {
3239
+ const line = buffer.slice(0, newline);
3240
+ buffer = buffer.slice(buffer[newline] === "\r" ? newline + 2 : newline + 1);
3241
+ if (line === "") {
3242
+ if (data.length > 0) yield {
3243
+ event,
3244
+ data: data.join("\n")
3245
+ };
3246
+ event = void 0;
3247
+ data = [];
3248
+ } else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
3249
+ else if (line.startsWith("event:")) event = line.slice(6).trim();
3250
+ newline = buffer.search(/\r?\n/);
3251
+ }
3252
+ if (done) return;
3253
+ }
3254
+ } finally {
3255
+ reader.releaseLock();
3256
+ }
3257
+ }
3258
+ //#endregion
3259
+ //#region ../charmd/src/agent/http.ts
3260
+ async function postStream(url, headers, body, signal) {
3261
+ const response = await fetch(url, {
3262
+ method: "POST",
3263
+ headers: {
3264
+ "content-type": "application/json",
3265
+ accept: "text/event-stream",
3266
+ ...headers
3267
+ },
3268
+ body: JSON.stringify(body),
3269
+ signal
3270
+ });
3271
+ if (!response.ok || !response.body) throw new Error(`The agent answered ${response.status} ${response.statusText} at ${url}`);
3272
+ return response.body;
3273
+ }
3274
+ async function* sseJson(body) {
3275
+ for await (const { data } of parseSse(body)) {
3276
+ if (data === "[DONE]") return;
3277
+ try {
3278
+ yield JSON.parse(data);
3279
+ } catch {}
3280
+ }
3281
+ }
3282
+ //#endregion
3283
+ //#region ../charmd/src/agent/hermes.ts
3284
+ function createHermesAgent(options) {
3285
+ return {
3286
+ name: "hermes",
3287
+ async *reply({ sessionKey, text, signal }) {
3288
+ const body = await postStream(`${options.baseUrl.replace(/\/$/, "")}/responses`, {
3289
+ ...options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {},
3290
+ "x-hermes-session-key": sessionKey
3291
+ }, {
3292
+ model: options.model,
3293
+ stream: true,
3294
+ input: text,
3295
+ conversation: sessionKey,
3296
+ store: true
3297
+ }, signal);
3298
+ for await (const event of sseJson(body)) {
3299
+ const { type, delta } = event;
3300
+ if (type === "response.output_text.delta" && delta) yield delta;
3301
+ }
3302
+ }
3303
+ };
3304
+ }
3305
+ //#endregion
3306
+ //#region ../charmd/src/agent/openai-compatible.ts
3307
+ function createOpenAiCompatibleAgent(options) {
3308
+ return {
3309
+ name: "openai-compatible",
3310
+ async *reply({ sessionKey, text, signal }) {
3311
+ const body = await postStream(`${options.baseUrl.replace(/\/$/, "")}/chat/completions`, options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}, {
3312
+ model: options.model,
3313
+ stream: true,
3314
+ user: sessionKey,
3315
+ messages: [{
3316
+ role: "user",
3317
+ content: text
3318
+ }]
3319
+ }, signal);
3320
+ for await (const chunk of sseJson(body)) {
3321
+ const content = chunk.choices?.[0]?.delta?.content;
3322
+ if (content) yield content;
3323
+ }
3324
+ }
3325
+ };
3326
+ }
3327
+ //#endregion
3328
+ //#region ../charmd/src/audio/opus-codec.ts
3329
+ const FRAME_MS = 60;
3330
+ function pcmToOggOpus(pcm, sampleRate) {
3331
+ const samples = sampleRate * FRAME_MS / 1e3;
3332
+ const frameBytes = samples * 2;
3333
+ const encoder = new OpusScript(sampleRate, 1, OpusScript.Application.AUDIO);
3334
+ const packets = [];
3335
+ try {
3336
+ for (let i = 0; i < pcm.length; i += frameBytes) {
3337
+ const frame = Buffer.alloc(frameBytes);
3338
+ pcm.copy(frame, 0, i, Math.min(i + frameBytes, pcm.length));
3339
+ packets.push(Buffer.from(encoder.encode(frame, samples)));
3340
+ }
3341
+ } finally {
3342
+ encoder.delete();
3343
+ }
3344
+ return writeOggOpus(packets, { inputSampleRate: sampleRate });
3345
+ }
3346
+ function oggOpusToPcm(ogg, sampleRate) {
3347
+ const decoder = new OpusScript(sampleRate, 1);
3348
+ try {
3349
+ return Buffer.concat(readOggOpus(ogg).packets.map((packet) => Buffer.from(decoder.decode(packet))));
3350
+ } finally {
3351
+ decoder.delete();
3352
+ }
3353
+ }
3354
+ //#endregion
3355
+ //#region ../charmd/src/voice/fake.ts
3356
+ const SAMPLE_RATE = 24e3;
3357
+ const MS_PER_CHAR = 55;
3358
+ function createFakeVoice(options = {}) {
3359
+ return {
3360
+ name: "fake",
3361
+ transcribe: () => options.fail ? Promise.reject(/* @__PURE__ */ new Error("The fake voice was told to fail")) : Promise.resolve(options.transcript ?? "hello"),
3362
+ synthesize: (text) => {
3363
+ const samples = Math.round(text.length * MS_PER_CHAR * SAMPLE_RATE / 1e3);
3364
+ const pcm = Buffer.alloc(samples * 2);
3365
+ for (let i = 0; i < samples; i++) pcm.writeInt16LE(Math.round(Math.sin(2 * Math.PI * 220 * i / SAMPLE_RATE) * 2e3), i * 2);
3366
+ return Promise.resolve(pcmToOggOpus(pcm, SAMPLE_RATE));
3367
+ }
3368
+ };
3369
+ }
3370
+ //#endregion
3371
+ //#region ../charmd/src/voice/wav.ts
3372
+ function writeWav(pcm, sampleRate, channels = 1) {
3373
+ const header = Buffer.alloc(44);
3374
+ header.write("RIFF", 0, "ascii");
3375
+ header.writeUInt32LE(36 + pcm.length, 4);
3376
+ header.write("WAVEfmt ", 8, "ascii");
3377
+ header.writeUInt32LE(16, 16);
3378
+ header.writeUInt16LE(1, 20);
3379
+ header.writeUInt16LE(channels, 22);
3380
+ header.writeUInt32LE(sampleRate, 24);
3381
+ header.writeUInt32LE(sampleRate * channels * 2, 28);
3382
+ header.writeUInt16LE(channels * 2, 32);
3383
+ header.writeUInt16LE(16, 34);
3384
+ header.write("data", 36, "ascii");
3385
+ header.writeUInt32LE(pcm.length, 40);
3386
+ return Buffer.concat([header, pcm]);
3387
+ }
3388
+ function readWav(data) {
3389
+ if (data.toString("ascii", 0, 4) !== "RIFF" || data.toString("ascii", 8, 12) !== "WAVE") throw new Error("Not a WAV file");
3390
+ let offset = 12;
3391
+ let format;
3392
+ while (offset + 8 <= data.length) {
3393
+ const id = data.toString("ascii", offset, offset + 4);
3394
+ const size = data.readUInt32LE(offset + 4);
3395
+ const body = offset + 8;
3396
+ if (id === "fmt ") format = {
3397
+ channels: data.readUInt16LE(body + 2),
3398
+ sampleRate: data.readUInt32LE(body + 4),
3399
+ bits: data.readUInt16LE(body + 14)
3400
+ };
3401
+ else if (id === "data") {
3402
+ if (!format || format.bits !== 16) throw new Error("Only 16-bit PCM WAV is supported");
3403
+ return {
3404
+ sampleRate: format.sampleRate,
3405
+ channels: format.channels,
3406
+ pcm: data.subarray(body, body + size)
3407
+ };
3408
+ }
3409
+ offset = body + size + size % 2;
3410
+ }
3411
+ throw new Error("WAV file has no data chunk");
3412
+ }
3413
+ //#endregion
3414
+ //#region ../charmd/src/voice/local.ts
3415
+ const DEFAULT_SAY_VOICE = "Samantha";
3416
+ function defaultWhisperModel() {
3417
+ return join(homedir(), ".opencharm", "models", "ggml-base.en.bin");
3418
+ }
3419
+ function run$1(command, args, signal) {
3420
+ return new Promise((resolve, reject) => {
3421
+ execFile(command, args, {
3422
+ signal,
3423
+ maxBuffer: 1048576
3424
+ }, (error, stdout, stderr) => {
3425
+ if (!error) {
3426
+ resolve(stdout);
3427
+ return;
3428
+ }
3429
+ const missing = error.code === "ENOENT";
3430
+ reject(/* @__PURE__ */ new Error(missing ? `The local voice needs "${command}" installed` : `${command} failed: ${stderr.trim().slice(0, 200)}`));
3431
+ });
3432
+ });
3433
+ }
3434
+ function sayArgs(voice, input, wav) {
3435
+ return [
3436
+ "-v",
3437
+ voice || DEFAULT_SAY_VOICE,
3438
+ "-f",
3439
+ input,
3440
+ "-o",
3441
+ wav,
3442
+ "--file-format=WAVE",
3443
+ "--data-format=LEI16@24000"
3444
+ ];
3445
+ }
3446
+ async function withTempDir(work) {
3447
+ const dir = mkdtempSync(join(tmpdir(), "opencharm-voice-"));
3448
+ try {
3449
+ return await work(dir);
3450
+ } finally {
3451
+ rmSync(dir, {
3452
+ recursive: true,
3453
+ force: true
3454
+ });
3455
+ }
3456
+ }
3457
+ function createLocalVoice(options) {
3458
+ const model = options.whisperModel ?? defaultWhisperModel();
3459
+ return {
3460
+ name: "local",
3461
+ transcribe: (ogg, signal) => withTempDir(async (dir) => {
3462
+ const wav = join(dir, "in.wav");
3463
+ writeFileSync(wav, writeWav(oggOpusToPcm(ogg, 16e3), 16e3));
3464
+ return (await run$1(options.whisperCommand ?? "whisper-cli", [
3465
+ "-m",
3466
+ model,
3467
+ "-f",
3468
+ wav,
3469
+ "-nt",
3470
+ "-np",
3471
+ "-l",
3472
+ "auto"
3473
+ ], signal)).replace(/\s+/g, " ").trim();
3474
+ }),
3475
+ synthesize: (text, signal) => withTempDir(async (dir) => {
3476
+ const input = join(dir, "text.txt");
3477
+ const wav = join(dir, "out.wav");
3478
+ writeFileSync(input, text);
3479
+ await run$1("say", sayArgs(options.sayVoice, input, wav), signal);
3480
+ const { pcm, sampleRate } = readWav(readFileSync(wav));
3481
+ if (sampleRate !== 24e3) throw new Error(`say produced ${sampleRate} Hz, expected 24000`);
3482
+ return pcmToOggOpus(pcm, 24e3);
3483
+ })
3484
+ };
3485
+ }
3486
+ //#endregion
3487
+ //#region ../charmd/src/voice/openai.ts
3488
+ function createOpenAiVoice(options) {
3489
+ const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
3490
+ if (!apiKey) throw new Error("The openai voice needs an API key: set voice.openai.apiKey or OPENAI_API_KEY");
3491
+ const baseUrl = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
3492
+ const auth = { authorization: `Bearer ${apiKey}` };
3493
+ return {
3494
+ name: "openai",
3495
+ async transcribe(ogg, signal) {
3496
+ const form = new FormData();
3497
+ form.append("model", options.sttModel ?? "gpt-4o-mini-transcribe");
3498
+ form.append("file", new Blob([new Uint8Array(ogg)], { type: "audio/ogg" }), "speech.ogg");
3499
+ const response = await fetch(`${baseUrl}/audio/transcriptions`, {
3500
+ method: "POST",
3501
+ headers: auth,
3502
+ body: form,
3503
+ signal
3504
+ });
3505
+ if (!response.ok) throw new Error(`Speech-to-text failed: ${response.status} ${response.statusText}`);
3506
+ const { text } = await response.json();
3507
+ return (text ?? "").trim();
3508
+ },
3509
+ async synthesize(text, signal) {
3510
+ const response = await fetch(`${baseUrl}/audio/speech`, {
3511
+ method: "POST",
3512
+ headers: {
3513
+ ...auth,
3514
+ "content-type": "application/json"
3515
+ },
3516
+ body: JSON.stringify({
3517
+ model: options.ttsModel ?? "gpt-4o-mini-tts",
3518
+ input: text,
3519
+ voice: options.voice ?? "alloy",
3520
+ response_format: "opus"
3521
+ }),
3522
+ signal
3523
+ });
3524
+ if (!response.ok) throw new Error(`Text-to-speech failed: ${response.status} ${response.statusText}`);
3525
+ return Buffer.from(await response.arrayBuffer());
3526
+ }
3527
+ };
3528
+ }
3529
+ //#endregion
3530
+ //#region ../charmd/src/wiring.ts
3531
+ function key(config, fallbackEnv) {
3532
+ const env = config.apiKeyEnv ?? fallbackEnv;
3533
+ return config.apiKey ?? (env ? process.env[env] : void 0);
3534
+ }
3535
+ function createVoiceFromConfig(config) {
3536
+ const voice = config.voice;
3537
+ switch (voice.provider) {
3538
+ case "fake": return createFakeVoice({ transcript: voice.transcript });
3539
+ case "local": return createLocalVoice(voice);
3540
+ case "openai": return createOpenAiVoice({
3541
+ ...voice,
3542
+ apiKey: key(voice, "OPENAI_API_KEY")
3543
+ });
3544
+ }
3545
+ }
3546
+ function charmToolsServers(config, tools) {
3547
+ const agent = config.agent;
3548
+ if (agent.adapter !== "acp" || !agent.charmTools || !tools) return [];
3549
+ return [{
3550
+ name: "charm",
3551
+ command: tools.command,
3552
+ args: [
3553
+ ...tools.args,
3554
+ "--socket",
3555
+ config.adminSocket
3556
+ ],
3557
+ env: []
3558
+ }];
3559
+ }
3560
+ function createAgentFromConfig(config, log = () => void 0, tools) {
3561
+ const agent = config.agent;
3562
+ switch (agent.adapter) {
3563
+ case "fake": return createFakeAgent();
3564
+ case "openai-compatible": return createOpenAiCompatibleAgent({
3565
+ ...agent,
3566
+ apiKey: key(agent)
3567
+ });
3568
+ case "openclaw": return createOpenAiCompatibleAgent({
3569
+ ...agent,
3570
+ apiKey: key(agent, "OPENCLAW_GATEWAY_TOKEN")
3571
+ });
3572
+ case "hermes": return createHermesAgent({
3573
+ ...agent,
3574
+ apiKey: key(agent, "API_SERVER_KEY")
3575
+ });
3576
+ case "acp": return createAcpAgent({
3577
+ ...agent,
3578
+ log,
3579
+ mcpServers: charmToolsServers(config, tools)
3580
+ });
3581
+ }
3582
+ }
3583
+ //#endregion
3584
+ //#region ../charmd/src/daemon.ts
3585
+ const CLOSE_POLICY = 1008;
3586
+ const CLOSE_BUSY = 1013;
3587
+ const HEARTBEAT_MS = 3e4;
3588
+ async function startDaemon(config, options = {}) {
3589
+ const unpairedIdleMs = options.timings?.unpairedIdleMs ?? TIMEOUTS.unpairedIdleSeconds * 1e3;
3590
+ const pairCodeMs = options.timings?.pairCodeMs ?? TIMEOUTS.pairCodeSeconds * 1e3;
3591
+ const store = new StateStore(config.statePath);
3592
+ const pairing = new PairingRegistry({
3593
+ ttlMs: pairCodeMs,
3594
+ maxPending: config.maxConnections
3595
+ });
3596
+ const sessions = /* @__PURE__ */ new Map();
3597
+ const voice = createVoiceFromConfig(config);
3598
+ const log = options.log ?? ((entry) => {
3599
+ if (!options.quiet) console.log(JSON.stringify({
3600
+ time: (/* @__PURE__ */ new Date()).toISOString(),
3601
+ ...entry
3602
+ }));
3603
+ });
3604
+ const agent = createAgentFromConfig(config, (message) => log({
3605
+ event: "agent",
3606
+ message
3607
+ }), options.charmTools);
3608
+ const { agentCanChangeLook, ...lookSettings } = config.charm;
3609
+ const look = new CharmLook(lookSettings, readAgentName(config.agent.adapter === "acp" ? config.agent.cwd : void 0));
3610
+ const admin = createAdminActions({
3611
+ store,
3612
+ pairing,
3613
+ sessions: () => sessions.values(),
3614
+ look,
3615
+ agentCanChangeLook
3616
+ });
3617
+ const adminServer = await startAdminServer(config.adminSocket, admin);
3618
+ let websocketUrl = config.publicUrl ?? "";
3619
+ const http = createServer$1((req, res) => {
3620
+ if (isOtaRequest(req)) {
3621
+ handleOta(res, websocketUrl);
3622
+ return;
3623
+ }
3624
+ res.writeHead(404).end();
3625
+ });
3626
+ const wss = new WebSocketServer({
3627
+ server: http,
3628
+ path: "/charm",
3629
+ maxPayload: LIMITS.maxJsonBytes,
3630
+ handleProtocols: (protocols) => protocols.has("opencharm") ? SUBPROTOCOL : false
3631
+ });
3632
+ wss.on("error", () => void 0);
3633
+ const answered = /* @__PURE__ */ new WeakSet();
3634
+ const heartbeat = setInterval(() => {
3635
+ for (const client of wss.clients) {
3636
+ if (!answered.has(client)) {
3637
+ client.terminate();
3638
+ continue;
3639
+ }
3640
+ answered.delete(client);
3641
+ client.ping();
3642
+ }
3643
+ }, options.timings?.heartbeatMs ?? HEARTBEAT_MS);
3644
+ wss.on("connection", (ws, req) => {
3645
+ if (sessions.size >= config.maxConnections) {
3646
+ ws.close(CLOSE_BUSY);
3647
+ return;
3648
+ }
3649
+ const session = new Session({
3650
+ store,
3651
+ pairing,
3652
+ connectionId: randomUUID(),
3653
+ token: tokenFrom(req),
3654
+ look: () => look.message(),
3655
+ outbound: {
3656
+ send: (message) => ws.send(JSON.stringify(message)),
3657
+ sendAudio: (packet) => ws.send(packet, { binary: true }),
3658
+ close: (code) => ws.close(code)
3659
+ },
3660
+ createTurn: (io) => new TurnController({
3661
+ ...io,
3662
+ voice,
3663
+ agent,
3664
+ timeoutMs: config.turnTimeoutSeconds * 1e3,
3665
+ logTranscripts: config.logTranscripts,
3666
+ log
3667
+ })
3668
+ });
3669
+ sessions.set(session.connectionId, session);
3670
+ answered.add(ws);
3671
+ ws.on("pong", () => answered.add(ws));
3672
+ const idle = setTimeout(() => {
3673
+ if (session.state === "awaiting_hello" || session.state === "unpaired") ws.close(CLOSE_POLICY);
3674
+ }, unpairedIdleMs);
3675
+ const refresh = setInterval(() => session.refreshPairCode(), pairCodeMs);
3676
+ ws.on("message", (data, isBinary) => {
3677
+ const buffer = toBuffer(data);
3678
+ if (isBinary) session.onBinary(buffer);
3679
+ else session.onText(buffer.toString("utf8"));
3680
+ });
3681
+ ws.on("close", () => {
3682
+ clearTimeout(idle);
3683
+ clearInterval(refresh);
3684
+ session.closed();
3685
+ sessions.delete(session.connectionId);
3686
+ });
3687
+ ws.on("error", () => ws.terminate());
3688
+ });
3689
+ try {
3690
+ await new Promise((resolve, reject) => {
3691
+ http.once("error", reject);
3692
+ http.listen(config.listen.port, config.listen.host, resolve);
3693
+ });
3694
+ } catch (error) {
3695
+ clearInterval(heartbeat);
3696
+ await new Promise((resolve) => adminServer.close(() => resolve()));
3697
+ throw error;
3698
+ }
3699
+ const { port } = http.address();
3700
+ const url = `ws://${config.listen.host.includes(":") ? `[${config.listen.host}]` : config.listen.host}:${port}/charm`;
3701
+ websocketUrl = config.publicUrl ?? url;
3702
+ if (!options.quiet) console.log(`charmd listening on ${url}`);
3703
+ return {
3704
+ url,
3705
+ admin,
3706
+ close: async () => {
3707
+ clearInterval(heartbeat);
3708
+ for (const client of wss.clients) client.terminate();
3709
+ agent.dispose?.();
3710
+ await new Promise((resolve) => wss.close(() => resolve()));
3711
+ await new Promise((resolve) => http.close(() => resolve()));
3712
+ await new Promise((resolve) => adminServer.close(() => resolve()));
3713
+ }
3714
+ };
3715
+ }
3716
+ //#endregion
3717
+ //#region src/commands/serve.ts
3718
+ async function runServe(ctx, args) {
3719
+ let config;
3720
+ let daemon;
3721
+ try {
3722
+ config = loadConfig(flagValue(args, "--config"));
3723
+ daemon = await startDaemon(config, {
3724
+ quiet: true,
3725
+ charmTools: {
3726
+ command: process.execPath,
3727
+ args: [
3728
+ ...process.execArgv.filter((arg) => !arg.startsWith("--inspect")),
3729
+ process.argv[1] ?? "",
3730
+ "mcp"
3731
+ ]
3732
+ },
3733
+ log: (entry) => ctx.out.write(`${JSON.stringify({
3734
+ time: (/* @__PURE__ */ new Date()).toISOString(),
3735
+ ...entry
3736
+ })}\n`)
3737
+ });
3738
+ } catch (error) {
3739
+ ctx.err.write(`charmd could not start: ${error instanceof Error ? error.message : String(error)}\n`);
3740
+ process.exitCode = 1;
3741
+ return;
3742
+ }
3743
+ ctx.out.write(`charmd (the charm daemon) is listening on ${daemon.url}\n`);
3744
+ ctx.out.write(`Admin socket: ${config.adminSocket}\n`);
3745
+ ctx.out.write("Pair a charm: opencharm pair <code> Stop: Ctrl-C\n");
3746
+ const stop = () => {
3747
+ daemon.close().then(() => process.exit(0));
3748
+ };
3749
+ process.once("SIGINT", stop);
3750
+ process.once("SIGTERM", stop);
3751
+ }
3752
+ //#endregion
3753
+ //#region src/commands/setup.ts
3754
+ const USER = "charmd";
3755
+ const STATE_DIR = "/var/lib/charmd";
3756
+ const CONFIG_DIR = "/etc/opencharm";
3757
+ const CONFIG = `${CONFIG_DIR}/charmd.json`;
3758
+ const ENV = `${CONFIG_DIR}/charmd.env`;
3759
+ const UNIT = "/etc/systemd/system/charmd.service";
3760
+ const HOST = /^(?=.{1,253}$)(?!.*\.\d+$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i;
3761
+ const HIDDEN = /^\/(home|root)(\/|$)/;
3762
+ function unit(node, cli) {
3763
+ return `# Installed by opencharm setup. charmd, the charm daemon, runs as its own user so the agent
3764
+ # running on this machine can't read its tokens, PINs or keys.
3765
+ [Unit]
3766
+ Description=charmd, the OpenCharm charm daemon
3767
+ After=network-online.target
3768
+ Wants=network-online.target
3769
+
3770
+ [Service]
3771
+ User=${USER}
3772
+ Group=${USER}
3773
+ EnvironmentFile=${ENV}
3774
+ ExecStart=${node} ${cli} serve --config ${CONFIG}
3775
+ Restart=on-failure
3776
+ RestartSec=2
3777
+ NoNewPrivileges=yes
3778
+ ProtectSystem=strict
3779
+ ProtectHome=yes
3780
+ PrivateTmp=yes
3781
+ PrivateDevices=yes
3782
+ ReadWritePaths=${STATE_DIR}
3783
+ CapabilityBoundingSet=
3784
+ AmbientCapabilities=
3785
+ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
3786
+ RestrictNamespaces=yes
3787
+ RestrictRealtime=yes
3788
+ RestrictSUIDSGID=yes
3789
+ LockPersonality=yes
3790
+ ProtectKernelTunables=yes
3791
+ ProtectKernelModules=yes
3792
+ ProtectKernelLogs=yes
3793
+ ProtectControlGroups=yes
3794
+ ProtectClock=yes
3795
+ ProtectHostname=yes
3796
+ SystemCallArchitectures=native
3797
+ UMask=0077
3798
+
3799
+ [Install]
3800
+ WantedBy=multi-user.target
3801
+ `;
3802
+ }
3803
+ function planSetup({ domain, node, cli, exists }) {
3804
+ if (!HOST.test(domain)) throw new Error(`"${domain}" is not a domain name (e.g. charm.example.com)`);
3805
+ for (const path of [node, cli]) if (HIDDEN.test(path)) throw new Error(`${path} is under /home or /root, which the hardened service can't see; install Node system-wide (e.g. NodeSource) and run: sudo npm install -g opencharm`);
3806
+ const config = {
3807
+ listen: {
3808
+ host: "127.0.0.1",
3809
+ port: 8787
3810
+ },
3811
+ statePath: `${STATE_DIR}/state.json`,
3812
+ publicUrl: `wss://${domain}/charm`,
3813
+ voice: { provider: "openai" },
3814
+ agent: { adapter: "hermes" },
3815
+ logTranscripts: false
3816
+ };
3817
+ const env = `# Keys for charmd (read by systemd; readable by root and charmd only).
3818
+ OPENAI_API_KEY=
3819
+ # Hermes' API server key (API_SERVER_KEY in Hermes' own .env).
3820
+ API_SERVER_KEY=
3821
+ `;
3822
+ const steps = [
3823
+ {
3824
+ kind: "user",
3825
+ name: USER,
3826
+ home: STATE_DIR
3827
+ },
3828
+ {
3829
+ kind: "dir",
3830
+ path: STATE_DIR,
3831
+ mode: 448,
3832
+ owner: USER,
3833
+ group: USER
3834
+ },
3835
+ {
3836
+ kind: "dir",
3837
+ path: CONFIG_DIR,
3838
+ mode: 488,
3839
+ owner: "root",
3840
+ group: USER
3841
+ }
3842
+ ];
3843
+ if (!exists(CONFIG)) steps.push({
3844
+ kind: "file",
3845
+ path: CONFIG,
3846
+ mode: 416,
3847
+ owner: "root",
3848
+ group: USER,
3849
+ content: `${JSON.stringify(config, null, 2)}\n`
3850
+ });
3851
+ if (!exists(ENV)) steps.push({
3852
+ kind: "file",
3853
+ path: ENV,
3854
+ mode: 416,
3855
+ owner: "root",
3856
+ group: USER,
3857
+ content: env
3858
+ });
3859
+ steps.push({
3860
+ kind: "file",
3861
+ path: UNIT,
3862
+ mode: 420,
3863
+ owner: "root",
3864
+ group: "root",
3865
+ content: unit(node, cli)
3866
+ }, {
3867
+ kind: "run",
3868
+ command: ["systemctl", "daemon-reload"]
3869
+ }, {
3870
+ kind: "run",
3871
+ command: [
3872
+ "systemctl",
3873
+ "enable",
3874
+ "charmd"
3875
+ ]
3876
+ });
3877
+ return steps;
3878
+ }
3879
+ function describe(step) {
3880
+ switch (step.kind) {
3881
+ case "user": return `create system user ${step.name} (no login, home ${step.home})`;
3882
+ case "dir": return `folder ${step.path} ${step.mode.toString(8)} ${step.owner}:${step.group}`;
3883
+ case "file": return `file ${step.path} ${step.mode.toString(8)} ${step.owner}:${step.group}`;
3884
+ case "run": return `run ${step.command.join(" ")}`;
3885
+ }
3886
+ }
3887
+ function run(command) {
3888
+ const [program, ...args] = command;
3889
+ if (spawnSync(program ?? "", args, { stdio: "inherit" }).status !== 0) throw new Error(`${command.join(" ")} failed`);
3890
+ }
3891
+ function apply(step) {
3892
+ switch (step.kind) {
3893
+ case "user":
3894
+ if (spawnSync("id", ["-u", step.name]).status !== 0) run([
3895
+ "useradd",
3896
+ "--system",
3897
+ "--home-dir",
3898
+ step.home,
3899
+ "--no-create-home",
3900
+ "--shell",
3901
+ "/usr/sbin/nologin",
3902
+ step.name
3903
+ ]);
3904
+ return;
3905
+ case "dir":
3906
+ mkdirSync(step.path, { recursive: true });
3907
+ chmodSync(step.path, step.mode);
3908
+ run([
3909
+ "chown",
3910
+ `${step.owner}:${step.group}`,
3911
+ step.path
3912
+ ]);
3913
+ return;
3914
+ case "file":
3915
+ writeFileSync(step.path, step.content, { mode: step.mode });
3916
+ chmodSync(step.path, step.mode);
3917
+ run([
3918
+ "chown",
3919
+ `${step.owner}:${step.group}`,
3920
+ step.path
3921
+ ]);
3922
+ return;
3923
+ case "run": run(step.command);
3924
+ }
3925
+ }
3926
+ function runSetup(ctx, args) {
3927
+ const fail = (message) => {
3928
+ ctx.err.write(`${message}\n`);
3929
+ process.exitCode = 1;
3930
+ };
3931
+ const domain = flagValue(args, "--domain");
3932
+ if (!domain) return fail("Usage: sudo opencharm setup --domain charm.example.com [--dry-run]");
3933
+ const dryRun = args.includes("--dry-run");
3934
+ let steps;
3935
+ try {
3936
+ steps = planSetup({
3937
+ domain,
3938
+ node: process.execPath,
3939
+ cli: realpathSync(fileURLToPath(import.meta.url)),
3940
+ exists: existsSync
3941
+ });
3942
+ } catch (error) {
3943
+ return fail(error instanceof Error ? error.message : String(error));
3944
+ }
3945
+ ctx.out.write(`opencharm setup will:\n${steps.map((s) => ` - ${describe(s)}`).join("\n")}\n\n`);
3946
+ if (!dryRun) {
3947
+ if (process.platform !== "linux") return fail("setup installs a systemd service; run it on the Linux machine next to your agent (try --dry-run here).");
3948
+ if (process.getuid?.() !== 0) return fail("setup changes system folders and users; run it with sudo.");
3949
+ try {
3950
+ for (const step of steps) apply(step);
3951
+ } catch (error) {
3952
+ return fail(`setup stopped: ${error instanceof Error ? error.message : String(error)}`);
3953
+ }
3954
+ }
3955
+ ctx.out.write(`${dryRun ? "Dry run: nothing changed.\n\n" : ""}Next:
3956
+ 1. Put your keys in ${ENV}, then: sudo systemctl start charmd
3957
+ 2. Caddy (/etc/caddy/Caddyfile), then: sudo systemctl reload caddy
3958
+
3959
+ ${domain} {
3960
+ reverse_proxy 127.0.0.1:8787
3961
+ }
3962
+
3963
+ 3. Pair a charm: sudo -u ${USER} opencharm pair <code> --config ${CONFIG}
3964
+ 4. Check: sudo -u ${USER} opencharm status --config ${CONFIG}
3965
+ `);
3966
+ }
3967
+ //#endregion
3968
+ //#region src/commands/sim.ts
3969
+ const DEFAULT_PORT = 5174;
3970
+ const TYPES = {
3971
+ ".html": "text/html; charset=utf-8",
3972
+ ".js": "text/javascript; charset=utf-8",
3973
+ ".css": "text/css; charset=utf-8",
3974
+ ".wasm": "application/wasm",
3975
+ ".json": "application/json"
3976
+ };
3977
+ function simDir() {
3978
+ let dir = dirname(fileURLToPath(import.meta.url));
3979
+ for (let i = 0; i < 4; i++) {
3980
+ const candidate = join(dir, "sim");
3981
+ if (existsSync(join(candidate, "charm_sim.wasm"))) return candidate;
3982
+ dir = dirname(dir);
3983
+ }
3984
+ }
3985
+ function createSimServer(dir, charmdUrl) {
3986
+ const root = resolve(dir);
3987
+ return createServer$1((req, res) => {
3988
+ let path;
3989
+ try {
3990
+ path = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
3991
+ } catch {
3992
+ res.writeHead(400).end();
3993
+ return;
3994
+ }
3995
+ if (path === "/config.json") {
3996
+ res.writeHead(200, { "content-type": TYPES[".json"] }).end(JSON.stringify({ url: charmdUrl }));
3997
+ return;
3998
+ }
3999
+ const file = resolve(root, normalize(`.${path === "/" ? "/index.html" : path}`));
4000
+ if (!file.startsWith(root + sep) || !existsSync(file) || !statSync(file).isFile()) {
4001
+ res.writeHead(404).end();
4002
+ return;
4003
+ }
4004
+ res.writeHead(200, {
4005
+ "content-type": TYPES[extname(file)] ?? "application/octet-stream",
4006
+ "cache-control": "no-store"
4007
+ }).end(readFileSync(file));
4008
+ });
4009
+ }
4010
+ function charmdUrlFrom(args) {
4011
+ const explicit = flagValue(args, "--url");
4012
+ if (explicit) return explicit;
4013
+ try {
4014
+ const { listen } = loadConfig(flagValue(args, "--config"));
4015
+ const host = listen.host === "0.0.0.0" || listen.host === "::" ? "127.0.0.1" : listen.host;
4016
+ return `ws://${host.includes(":") ? `[${host}]` : host}:${listen.port}/charm`;
4017
+ } catch {
4018
+ return "ws://127.0.0.1:8787/charm";
4019
+ }
4020
+ }
4021
+ function openBrowser(url) {
4022
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
4023
+ "/c",
4024
+ "start",
4025
+ "",
4026
+ url
4027
+ ]] : ["xdg-open", [url]];
4028
+ spawn(command, args, {
4029
+ stdio: "ignore",
4030
+ detached: true
4031
+ }).on("error", () => void 0).unref();
4032
+ }
4033
+ async function runSim(ctx, args) {
4034
+ const dir = simDir();
4035
+ if (!dir) {
4036
+ ctx.err.write("The emulator isn't built in this install. From the repo: npm run firmware:sim\n");
4037
+ process.exitCode = 1;
4038
+ return;
4039
+ }
4040
+ const url = charmdUrlFrom(args);
4041
+ const port = Number(flagValue(args, "--port") ?? DEFAULT_PORT);
4042
+ const server = createSimServer(dir, url);
4043
+ try {
4044
+ await new Promise((resolveListen, reject) => {
4045
+ server.once("error", reject);
4046
+ server.listen(port, "127.0.0.1", resolveListen);
4047
+ });
4048
+ } catch (error) {
4049
+ ctx.err.write(`The emulator could not start: ${error instanceof Error ? error.message : String(error)}\n`);
4050
+ process.exitCode = 1;
4051
+ return;
4052
+ }
4053
+ const page = `http://127.0.0.1:${port}/`;
4054
+ ctx.out.write(`The emulator is at ${page} (charmd: ${url}). Hold Space to talk. Stop: Ctrl-C\n`);
4055
+ if (!args.includes("--no-open")) openBrowser(page);
4056
+ }
4057
+ //#endregion
4058
+ //#region src/commands/states.ts
4059
+ function runStates(ctx, args) {
4060
+ const { fg, bg, bold, dim, reset } = ctx.style;
4061
+ const colour = pickColour(args, (message) => ctx.err.write(`${message}\n`));
4062
+ ctx.out.write(`\n ${bold("What your agent is doing → the face")}\n\n`);
4063
+ for (const state of facesData.states) {
4064
+ const face = findFace(state.face);
4065
+ const text = (face ? faceText(face) : state.face).padEnd(7);
4066
+ const ink = state.face === "ask" ? ctx.signal : colour.g;
4067
+ ctx.out.write(` ${fg(ink)}${bg("#000000")} ${bold(text)} ${reset} ${bold(state.name.padEnd(10))} ${state.when}\n ${" ".repeat(12)}${dim(`trigger: ${state.trigger}`)}\n`);
4068
+ }
4069
+ ctx.out.write(`\n ${dim("Orange means one thing only: it needs you.")}\n\n`);
4070
+ }
4071
+ //#endregion
4072
+ //#region src/main.ts
4073
+ const DEFAULT_SIGNAL = "#FF5A1F";
4074
+ function buildContext(argv) {
4075
+ const options = detectStyleOptions(argv, process.env, process.stdout.isTTY === true);
4076
+ return {
4077
+ out: process.stdout,
4078
+ err: process.stderr,
4079
+ style: createStyle(options),
4080
+ canAnimate: options.color && !argv.includes("--no-anim") && !process.env.CI,
4081
+ version,
4082
+ signal: facesData.signal || DEFAULT_SIGNAL
4083
+ };
4084
+ }
4085
+ async function main(argv) {
4086
+ const ctx = buildContext(argv);
4087
+ const { command, rest, help, version } = parseArgs(argv);
4088
+ if (version) {
4089
+ ctx.out.write(`${ctx.version}\n`);
4090
+ return;
4091
+ }
4092
+ if (help || command === "help") {
4093
+ runHelp(ctx);
4094
+ return;
4095
+ }
4096
+ switch (command) {
4097
+ case "":
4098
+ await runHello(ctx, argv);
4099
+ return;
4100
+ case "face":
4101
+ await runFace(ctx, rest);
4102
+ return;
4103
+ case "faces":
4104
+ runFaces(ctx, rest);
4105
+ return;
4106
+ case "states":
4107
+ runStates(ctx, rest);
4108
+ return;
4109
+ case "hardware":
4110
+ runHardware(ctx);
4111
+ return;
4112
+ case "init":
4113
+ runInit(ctx, rest);
4114
+ return;
4115
+ case "sim":
4116
+ await runSim(ctx, rest);
4117
+ return;
4118
+ case "setup":
4119
+ runSetup(ctx, rest);
4120
+ return;
4121
+ case "serve":
4122
+ await runServe(ctx, rest);
4123
+ return;
4124
+ case "mcp": {
4125
+ const socket = flagValue(rest, "--socket") ?? loadConfig(flagValue(rest, "--config")).adminSocket;
4126
+ await serveMcp(process.stdin, process.stdout, {
4127
+ version: ctx.version,
4128
+ send: (request) => sendAdmin(socket, request)
4129
+ });
4130
+ return;
4131
+ }
4132
+ case "pair":
4133
+ case "lock":
4134
+ case "unlock":
4135
+ case "revoke":
4136
+ case "status":
4137
+ case "dev":
4138
+ case "look":
4139
+ await runAdminCommand(ctx, command, rest, {
4140
+ socket: loadConfig(flagValue(rest, "--config")).adminSocket,
4141
+ send: sendAdmin,
4142
+ prompt: promptHidden
4143
+ });
4144
+ return;
4145
+ default:
4146
+ ctx.out.write(`Unknown command "${command}".\n`);
4147
+ runHelp(ctx);
4148
+ process.exitCode = 1;
4149
+ }
4150
+ }
4151
+ process.stdout.on("error", (error) => {
4152
+ if (error.code === "EPIPE") process.exit(0);
4153
+ throw error;
4154
+ });
4155
+ await main(process.argv.slice(2));
4156
+ //#endregion
4157
+ export {};