opencharm 0.4.0 → 0.5.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 +1 -0
- package/dist/main.mjs +101 -14
- package/package.json +1 -1
- package/sim/charm_sim.wasm +0 -0
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ npx opencharm
|
|
|
20
20
|
| `opencharm pair <code>` | Pair the charm showing that code; you choose its PIN (`--name <name>`) |
|
|
21
21
|
| `opencharm status` | What's running (charmd, each connected charm's build, the starter commit of the workspace; spec 015), then the paired charms and whether they are connected, locked or blocked |
|
|
22
22
|
| `opencharm lock` / `unlock` / `revoke <charm>` | Lock it now; clear a block and unlock; forget it |
|
|
23
|
+
| `opencharm replies on\|off` | The desktop charm speaks its replies, or shows them as text, from the next one; a charm on a board always speaks. To keep it, set `"speakReplies": false` in `opencharm.json` |
|
|
23
24
|
| `opencharm look` | Show the charm's look, or change it on every unlocked charm until charmd restarts (`--colour <colour>`, `--greeting <text>`, `--motion full\|calm`); to keep it, set `charm` in `opencharm.json` |
|
|
24
25
|
| `opencharm dev face <charm> <state> [text]` / `dev say <charm> <text>` / `dev ask <charm> <question>` | Show a face, speak, or ask a question (hold = yes, press = no) on a connected charm (testing) |
|
|
25
26
|
| `opencharm voice` / `voice install` | The local voice's models (Parakeet listening, Supertonic speaking): whether they're here, and download them now (charmd does it on its first start otherwise) |
|
package/dist/main.mjs
CHANGED
|
@@ -828,6 +828,7 @@ const schema = z.object({
|
|
|
828
828
|
agent: agentSchema.default({ adapter: "fake" }),
|
|
829
829
|
turnTimeoutSeconds: z.number().int().min(5).max(600).default(60),
|
|
830
830
|
logTranscripts: z.boolean().default(false),
|
|
831
|
+
speakReplies: z.boolean().default(true),
|
|
831
832
|
charm: charmSchema.prefault({})
|
|
832
833
|
}).strict();
|
|
833
834
|
function defaultAdminSocket(statePath) {
|
|
@@ -961,7 +962,7 @@ function sendAdmin(socketPath, request) {
|
|
|
961
962
|
}
|
|
962
963
|
//#endregion
|
|
963
964
|
//#region package.json
|
|
964
|
-
var version = "0.
|
|
965
|
+
var version = "0.5.0";
|
|
965
966
|
function channelToCube(value) {
|
|
966
967
|
if (value < 48) return 0;
|
|
967
968
|
if (value < 115) return 1;
|
|
@@ -1225,6 +1226,19 @@ async function runAdminCommand(ctx, command, args, deps) {
|
|
|
1225
1226
|
await look(ctx, args, deps);
|
|
1226
1227
|
return;
|
|
1227
1228
|
}
|
|
1229
|
+
if (command === "replies") {
|
|
1230
|
+
const choice = firstPositional(args);
|
|
1231
|
+
if (choice !== "on" && choice !== "off") {
|
|
1232
|
+
fail$2(ctx, "Usage: opencharm replies on|off (spoken, or text on the desktop charm)");
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
await deps.send(deps.socket, {
|
|
1236
|
+
cmd: "replies",
|
|
1237
|
+
speak: choice === "on"
|
|
1238
|
+
});
|
|
1239
|
+
ctx.out.write(choice === "on" ? "Replies are spoken, from the next one.\n" : "Replies show as text on the desktop charm, from the next one (a charm on a board still speaks).\n");
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1228
1242
|
if (command === "status") {
|
|
1229
1243
|
const answer = statusSchema.safeParse(await deps.send(deps.socket, { cmd: "status" }));
|
|
1230
1244
|
if (!answer.success) {
|
|
@@ -1452,6 +1466,7 @@ function runHelp(ctx) {
|
|
|
1452
1466
|
opencharm lock <charm> lock a charm now
|
|
1453
1467
|
opencharm unlock <charm> clear a block and unlock
|
|
1454
1468
|
opencharm revoke <charm> forget a charm (it must pair again)
|
|
1469
|
+
opencharm replies on|off the desktop charm speaks its replies, or shows them as text
|
|
1455
1470
|
opencharm look show or change the charm's look until charmd restarts [--colour <c>] [--greeting <text>] [--motion full|calm]
|
|
1456
1471
|
opencharm dev face <charm> <state> [text] show a face (testing)
|
|
1457
1472
|
opencharm dev say <charm> <text> speak through it (testing)
|
|
@@ -1796,6 +1811,7 @@ async function serveMcp(input, output, deps) {
|
|
|
1796
1811
|
}
|
|
1797
1812
|
//#endregion
|
|
1798
1813
|
//#region ../charmd/src/admin/admin-actions.ts
|
|
1814
|
+
const repliesInput = z.object({ speak: z.boolean() }).strict();
|
|
1799
1815
|
const pairInput = z.object({
|
|
1800
1816
|
code: z.string().regex(/^\d{6}$/, "the code has 6 digits"),
|
|
1801
1817
|
pin: z.string().regex(/^\d{4,12}$/, "the PIN is 4 to 12 digits"),
|
|
@@ -1829,7 +1845,7 @@ function parseInput(schema, input) {
|
|
|
1829
1845
|
if (!result.success) throw new Error(result.error.issues.map((i) => i.message).join("; "));
|
|
1830
1846
|
return result.data;
|
|
1831
1847
|
}
|
|
1832
|
-
function createAdminActions({ store, pairing, sessions, look, agentCanChangeLook, identity, starter, voice }) {
|
|
1848
|
+
function createAdminActions({ store, pairing, sessions, look, agentCanChangeLook, identity, starter, voice, replies }) {
|
|
1833
1849
|
function sessionsOf(charmId) {
|
|
1834
1850
|
return [...sessions()].filter((s) => s.charmId === charmId && s.state !== "closed");
|
|
1835
1851
|
}
|
|
@@ -1934,6 +1950,14 @@ function createAdminActions({ store, pairing, sessions, look, agentCanChangeLook
|
|
|
1934
1950
|
};
|
|
1935
1951
|
});
|
|
1936
1952
|
},
|
|
1953
|
+
replies(input) {
|
|
1954
|
+
return settled(() => {
|
|
1955
|
+
const { speak } = parseInput(repliesInput, input);
|
|
1956
|
+
if (!replies) throw new Error("This charmd can't change how replies are given.");
|
|
1957
|
+
replies.speak = speak;
|
|
1958
|
+
return { speak };
|
|
1959
|
+
});
|
|
1960
|
+
},
|
|
1937
1961
|
status() {
|
|
1938
1962
|
return {
|
|
1939
1963
|
charmd: identity ?? "unknown",
|
|
@@ -1967,7 +1991,8 @@ const COMMANDS = [
|
|
|
1967
1991
|
"devFace",
|
|
1968
1992
|
"devSay",
|
|
1969
1993
|
"devAsk",
|
|
1970
|
-
"look"
|
|
1994
|
+
"look",
|
|
1995
|
+
"replies"
|
|
1971
1996
|
];
|
|
1972
1997
|
function isCommand(value) {
|
|
1973
1998
|
return typeof value === "string" && COMMANDS.includes(value);
|
|
@@ -2248,7 +2273,8 @@ var Session = class {
|
|
|
2248
2273
|
sendAudio: (packet) => {
|
|
2249
2274
|
if (this.#state === "unlocked") this.#deps.outbound.sendAudio?.(packet);
|
|
2250
2275
|
},
|
|
2251
|
-
ask: (text, options) => this.ask(text, options)
|
|
2276
|
+
ask: (text, options) => this.ask(text, options),
|
|
2277
|
+
kind: () => this.#build?.kind
|
|
2252
2278
|
});
|
|
2253
2279
|
return this.#turn;
|
|
2254
2280
|
}
|
|
@@ -3220,6 +3246,10 @@ var SentenceSplitter = class {
|
|
|
3220
3246
|
#buffer = "";
|
|
3221
3247
|
#pending = "";
|
|
3222
3248
|
#started = false;
|
|
3249
|
+
#firstClause;
|
|
3250
|
+
constructor(options = {}) {
|
|
3251
|
+
this.#firstClause = options.firstClause ?? true;
|
|
3252
|
+
}
|
|
3223
3253
|
#take(piece) {
|
|
3224
3254
|
const text = piece.replace(/\s+/g, " ").trim();
|
|
3225
3255
|
if (!text) return [];
|
|
@@ -3234,7 +3264,7 @@ var SentenceSplitter = class {
|
|
|
3234
3264
|
this.#buffer += chunk;
|
|
3235
3265
|
const out = [];
|
|
3236
3266
|
let start = 0;
|
|
3237
|
-
for (const match of this.#buffer.matchAll(this.#started ? BOUNDARY : FIRST_BOUNDARY)) {
|
|
3267
|
+
for (const match of this.#buffer.matchAll(this.#started || !this.#firstClause ? BOUNDARY : FIRST_BOUNDARY)) {
|
|
3238
3268
|
const end = match.index + match[0].length;
|
|
3239
3269
|
if (/^[,;:]/.test(match[0]) && (this.#started || this.#pending.length + end - start < FIRST_CLAUSE_CHARS)) continue;
|
|
3240
3270
|
const lastWord = this.#buffer.slice(start, end).trim().split(/\s+/).at(-1)?.toLowerCase() ?? "";
|
|
@@ -3259,6 +3289,8 @@ function cleanForSpeech(text) {
|
|
|
3259
3289
|
//#endregion
|
|
3260
3290
|
//#region ../charmd/src/turn/turn.ts
|
|
3261
3291
|
const MAX_FRAMES = 1e3;
|
|
3292
|
+
const READING_WORDS_PER_SECOND = 3;
|
|
3293
|
+
const MIN_READING_MS = 2e3;
|
|
3262
3294
|
const LOOKAHEAD = 2;
|
|
3263
3295
|
const AGENT_LABELS = { ...Object.fromEntries(Object.entries(ACP_AGENTS).map(([name, agent]) => [name, agent.label])) };
|
|
3264
3296
|
var TurnTimeout = class extends Error {};
|
|
@@ -3269,6 +3301,10 @@ var StageError = class extends Error {
|
|
|
3269
3301
|
this.stage = stage;
|
|
3270
3302
|
}
|
|
3271
3303
|
};
|
|
3304
|
+
function readingMs(text) {
|
|
3305
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
3306
|
+
return Math.max(MIN_READING_MS, words / READING_WORDS_PER_SECOND * 1e3);
|
|
3307
|
+
}
|
|
3272
3308
|
function sleepMs(ms) {
|
|
3273
3309
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3274
3310
|
}
|
|
@@ -3374,6 +3410,7 @@ var TurnController = class {
|
|
|
3374
3410
|
let timer = startClock();
|
|
3375
3411
|
let speakingStarted = false;
|
|
3376
3412
|
let asking = false;
|
|
3413
|
+
let reading = false;
|
|
3377
3414
|
const restartClock = () => {
|
|
3378
3415
|
clearTimeout(timer);
|
|
3379
3416
|
timer = startClock();
|
|
@@ -3384,7 +3421,15 @@ var TurnController = class {
|
|
|
3384
3421
|
restartClock();
|
|
3385
3422
|
},
|
|
3386
3423
|
touch: () => {
|
|
3387
|
-
if (speakingStarted && !asking) restartClock();
|
|
3424
|
+
if (speakingStarted && !asking && !reading) restartClock();
|
|
3425
|
+
},
|
|
3426
|
+
pause: () => {
|
|
3427
|
+
reading = true;
|
|
3428
|
+
clearTimeout(timer);
|
|
3429
|
+
},
|
|
3430
|
+
resume: () => {
|
|
3431
|
+
reading = false;
|
|
3432
|
+
restartClock();
|
|
3388
3433
|
}
|
|
3389
3434
|
};
|
|
3390
3435
|
const timeoutGuard = new Promise((_, reject) => signal.addEventListener("abort", () => {
|
|
@@ -3429,7 +3474,7 @@ var TurnController = class {
|
|
|
3429
3474
|
signal,
|
|
3430
3475
|
...ask ? { ask: (action) => this.ask(`${label} wants to ${action}.`) } : {}
|
|
3431
3476
|
});
|
|
3432
|
-
said = await this.#speak(reply, signal, live, timings, started, timeoutGuard, guessLanguage(heard) ?? this.#deps.voice.language ?? "en", clock);
|
|
3477
|
+
said = await this.#speak(reply, signal, live, timings, started, timeoutGuard, guessLanguage(heard) ?? this.#deps.voice.language ?? "en", clock, this.#deps.speakAloud?.() ?? true);
|
|
3433
3478
|
if (!live()) return;
|
|
3434
3479
|
if (this.#speaking) this.#deps.send({
|
|
3435
3480
|
type: "tts",
|
|
@@ -3471,7 +3516,7 @@ var TurnController = class {
|
|
|
3471
3516
|
});
|
|
3472
3517
|
}
|
|
3473
3518
|
}
|
|
3474
|
-
async #speak(source, signal, live, timings, started, timeoutGuard, language, clock) {
|
|
3519
|
+
async #speak(source, signal, live, timings, started, timeoutGuard, language, clock, aloud) {
|
|
3475
3520
|
const now = this.#deps.now ?? Date.now;
|
|
3476
3521
|
const going = () => live() && !signal.aborted;
|
|
3477
3522
|
let speaking = language;
|
|
@@ -3497,11 +3542,11 @@ var TurnController = class {
|
|
|
3497
3542
|
text,
|
|
3498
3543
|
language: sentenceLanguage
|
|
3499
3544
|
});
|
|
3500
|
-
startUpTo(playing + LOOKAHEAD);
|
|
3545
|
+
if (aloud) startUpTo(playing + LOOKAHEAD);
|
|
3501
3546
|
wake();
|
|
3502
3547
|
};
|
|
3503
3548
|
const produce = (async () => {
|
|
3504
|
-
const splitter = new SentenceSplitter();
|
|
3549
|
+
const splitter = new SentenceSplitter({ firstClause: aloud });
|
|
3505
3550
|
await inStage("agent", async () => {
|
|
3506
3551
|
for await (const chunk of source) {
|
|
3507
3552
|
if (!going()) return;
|
|
@@ -3524,6 +3569,11 @@ var TurnController = class {
|
|
|
3524
3569
|
await new Promise((resolve) => wake = resolve);
|
|
3525
3570
|
continue;
|
|
3526
3571
|
}
|
|
3572
|
+
if (!aloud) {
|
|
3573
|
+
await this.#showForReading(queue[playing].text, signal, going, clock, timings, started);
|
|
3574
|
+
playing += 1;
|
|
3575
|
+
continue;
|
|
3576
|
+
}
|
|
3527
3577
|
startUpTo(playing + LOOKAHEAD);
|
|
3528
3578
|
const { text, packets } = queue[playing];
|
|
3529
3579
|
if (!packets) throw new Error("A sentence wasn't started");
|
|
@@ -3563,9 +3613,20 @@ var TurnController = class {
|
|
|
3563
3613
|
clearTimeout(timer);
|
|
3564
3614
|
timer = startClock();
|
|
3565
3615
|
};
|
|
3616
|
+
let reading = false;
|
|
3566
3617
|
const clock = {
|
|
3567
3618
|
started: restartClock,
|
|
3568
|
-
touch:
|
|
3619
|
+
touch: () => {
|
|
3620
|
+
if (!reading) restartClock();
|
|
3621
|
+
},
|
|
3622
|
+
pause: () => {
|
|
3623
|
+
reading = true;
|
|
3624
|
+
clearTimeout(timer);
|
|
3625
|
+
},
|
|
3626
|
+
resume: () => {
|
|
3627
|
+
reading = false;
|
|
3628
|
+
restartClock();
|
|
3629
|
+
}
|
|
3569
3630
|
};
|
|
3570
3631
|
const timeoutGuard = new Promise((_, reject) => controller.signal.addEventListener("abort", () => {
|
|
3571
3632
|
if (controller.signal.reason instanceof TurnTimeout) reject(controller.signal.reason);
|
|
@@ -3575,7 +3636,7 @@ var TurnController = class {
|
|
|
3575
3636
|
yield await Promise.resolve(text);
|
|
3576
3637
|
}
|
|
3577
3638
|
try {
|
|
3578
|
-
await this.#speak(once(), controller.signal, live, {}, Date.now(), timeoutGuard, guessLanguage(text) ?? this.#deps.voice.language ?? "en", clock);
|
|
3639
|
+
await this.#speak(once(), controller.signal, live, {}, Date.now(), timeoutGuard, guessLanguage(text) ?? this.#deps.voice.language ?? "en", clock, this.#deps.speakAloud?.() ?? true);
|
|
3579
3640
|
if (!live()) return;
|
|
3580
3641
|
if (this.#speaking) this.#deps.send({
|
|
3581
3642
|
type: "tts",
|
|
@@ -3598,6 +3659,28 @@ var TurnController = class {
|
|
|
3598
3659
|
clearTimeout(timer);
|
|
3599
3660
|
}
|
|
3600
3661
|
}
|
|
3662
|
+
async #showForReading(text, signal, going, clock, timings, started) {
|
|
3663
|
+
if (!going()) return;
|
|
3664
|
+
if (!this.#speaking) {
|
|
3665
|
+
this.#speaking = true;
|
|
3666
|
+
this.#state = "speaking";
|
|
3667
|
+
timings.firstTextMs = (this.#deps.now ?? Date.now)() - started;
|
|
3668
|
+
clock.started();
|
|
3669
|
+
this.#deps.send({
|
|
3670
|
+
type: "tts",
|
|
3671
|
+
state: "start"
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
this.#deps.send({
|
|
3675
|
+
type: "tts",
|
|
3676
|
+
state: "sentence_start",
|
|
3677
|
+
text
|
|
3678
|
+
});
|
|
3679
|
+
const sleep = this.#deps.sleep ?? sleepMs;
|
|
3680
|
+
clock.pause();
|
|
3681
|
+
await Promise.race([sleep(readingMs(text)), new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))]);
|
|
3682
|
+
clock.resume();
|
|
3683
|
+
}
|
|
3601
3684
|
#failureLine(error) {
|
|
3602
3685
|
if (error instanceof TurnTimeout) return "That took too long.";
|
|
3603
3686
|
if (error instanceof StageError && error.cause instanceof VoiceNotReady) return error.cause.message;
|
|
@@ -4670,6 +4753,7 @@ async function startDaemon(config, options = {}) {
|
|
|
4670
4753
|
});
|
|
4671
4754
|
const sessions = /* @__PURE__ */ new Map();
|
|
4672
4755
|
const voice = createVoiceFromConfig(config);
|
|
4756
|
+
const replies = { speak: config.speakReplies };
|
|
4673
4757
|
voice.warm?.().catch(() => void 0);
|
|
4674
4758
|
const log = options.log ?? ((entry) => {
|
|
4675
4759
|
if (!options.quiet) console.log(JSON.stringify({
|
|
@@ -4691,6 +4775,7 @@ async function startDaemon(config, options = {}) {
|
|
|
4691
4775
|
agentCanChangeLook,
|
|
4692
4776
|
identity: options.identity,
|
|
4693
4777
|
starter: options.starter,
|
|
4778
|
+
replies,
|
|
4694
4779
|
voice: () => {
|
|
4695
4780
|
const downloading = ["parakeet", "supertonic"].flatMap((id) => {
|
|
4696
4781
|
const state = modelState(id);
|
|
@@ -4749,7 +4834,8 @@ async function startDaemon(config, options = {}) {
|
|
|
4749
4834
|
agent,
|
|
4750
4835
|
timeoutMs: config.turnTimeoutSeconds * 1e3,
|
|
4751
4836
|
logTranscripts: config.logTranscripts,
|
|
4752
|
-
log
|
|
4837
|
+
log,
|
|
4838
|
+
speakAloud: () => io.kind() !== "desktop" || replies.speak
|
|
4753
4839
|
})
|
|
4754
4840
|
});
|
|
4755
4841
|
sessions.set(session.connectionId, session);
|
|
@@ -4831,7 +4917,7 @@ function formatIdentity(unit, version, commit) {
|
|
|
4831
4917
|
let cached;
|
|
4832
4918
|
function identity() {
|
|
4833
4919
|
if (cached) return cached;
|
|
4834
|
-
const commit = "
|
|
4920
|
+
const commit = "385e52a";
|
|
4835
4921
|
cached = {
|
|
4836
4922
|
version: `cli@${version}`,
|
|
4837
4923
|
commit,
|
|
@@ -5311,6 +5397,7 @@ async function main(argv) {
|
|
|
5311
5397
|
case "status":
|
|
5312
5398
|
case "dev":
|
|
5313
5399
|
case "look":
|
|
5400
|
+
case "replies":
|
|
5314
5401
|
await runAdminCommand(ctx, command, rest, {
|
|
5315
5402
|
socket: loadConfig(flagValue(rest, "--config")).adminSocket,
|
|
5316
5403
|
send: sendAdmin,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencharm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "An open-source body for the AI agent you already run: a face, a voice, one key. Claude Code, Codex, Gemini CLI, Hermes, OpenClaw or any ACP or OpenAI-compatible agent; on a small board, your Mac's notch or the browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/sim/charm_sim.wasm
CHANGED
|
Binary file
|