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/README.md +30 -4
- package/dist/main.mjs +4242 -0
- package/package.json +52 -4
- package/sim/THIRD_PARTY_NOTICES.md +95 -0
- package/sim/charm_sim.js +2 -0
- package/sim/charm_sim.wasm +0 -0
- package/sim/index.html +68 -0
- package/sim/mic-worklet.js +22 -0
- package/sim/sim.css +148 -0
- package/sim/sim.js +412 -0
- package/LICENSE +0 -21
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 ? `[1m${text}[22m` : text,
|
|
902
|
+
dim: (text) => color ? `[2m${text}[22m` : text,
|
|
903
|
+
reset: color ? `[0m` : ""
|
|
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}[K`).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 {};
|