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 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.4.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: restartClock
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 = "42da0c7";
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.4.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": {
Binary file