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