@frockbot/plugin-shell 0.3.13 → 0.3.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,6 +27,7 @@
27
27
  "./client/FrockBotApp.vue": "./src/client/FrockBotApp.vue",
28
28
  "./client/notify": "./src/client/notify.ts",
29
29
  "./client/styles.css": "./src/client/styles.css",
30
+ "./client/voice-worklet": "./src/client/voice-worklet.ts",
30
31
  "./focus": "./src/focus.ts",
31
32
  "./frockbot.json": "./frockbot.json",
32
33
  "./host": "./src/host.ts",
@@ -50,42 +51,42 @@
50
51
  },
51
52
  "dependencies": {
52
53
  "@cordisjs/plugin-webui": "0.8.2",
53
- "@frockbot/agent-runtime": "0.3.13",
54
- "@frockbot/application-foundation": "0.3.13",
55
- "@frockbot/catalog-core": "0.3.13",
56
- "@frockbot/client-core": "0.3.13",
57
- "@frockbot/client-ui": "0.3.13",
58
- "@frockbot/computer-core": "0.3.13",
59
- "@frockbot/computer-host-protocol": "0.3.13",
60
- "@frockbot/configuration-core": "0.3.13",
61
- "@frockbot/connection-core": "0.3.13",
62
- "@frockbot/kernel-agent-loop": "0.3.13",
63
- "@frockbot/kernel-composition": "0.3.13",
64
- "@frockbot/kernel-contracts": "0.3.13",
65
- "@frockbot/kernel-do": "0.3.13",
66
- "@frockbot/machine-protocol": "0.3.13",
67
- "@frockbot/plugin-applets": "0.3.13",
68
- "@frockbot/plugin-authoring": "0.3.13",
69
- "@frockbot/plugin-bot-template": "0.3.13",
70
- "@frockbot/plugin-computer": "0.3.13",
71
- "@frockbot/plugin-flock": "0.3.13",
72
- "@frockbot/plugin-image": "0.3.13",
73
- "@frockbot/plugin-machine-messages": "0.3.13",
74
- "@frockbot/plugin-mcp": "0.3.13",
75
- "@frockbot/plugin-memory": "0.3.13",
76
- "@frockbot/plugin-package-catalog": "0.3.13",
77
- "@frockbot/plugin-routines": "0.3.13",
78
- "@frockbot/plugin-skills": "0.3.13",
79
- "@frockbot/plugin-subagents": "0.3.13",
80
- "@frockbot/plugin-user-machine": "0.3.13",
81
- "@frockbot/protocol": "0.3.13",
54
+ "@frockbot/agent-runtime": "0.3.15",
55
+ "@frockbot/application-foundation": "0.3.15",
56
+ "@frockbot/catalog-core": "0.3.15",
57
+ "@frockbot/client-core": "0.3.15",
58
+ "@frockbot/client-ui": "0.3.15",
59
+ "@frockbot/computer-core": "0.3.15",
60
+ "@frockbot/computer-host-protocol": "0.3.15",
61
+ "@frockbot/configuration-core": "0.3.15",
62
+ "@frockbot/connection-core": "0.3.15",
63
+ "@frockbot/kernel-agent-loop": "0.3.15",
64
+ "@frockbot/kernel-composition": "0.3.15",
65
+ "@frockbot/kernel-contracts": "0.3.15",
66
+ "@frockbot/kernel-do": "0.3.15",
67
+ "@frockbot/machine-protocol": "0.3.15",
68
+ "@frockbot/plugin-applets": "0.3.15",
69
+ "@frockbot/plugin-authoring": "0.3.15",
70
+ "@frockbot/plugin-bot-template": "0.3.15",
71
+ "@frockbot/plugin-computer": "0.3.15",
72
+ "@frockbot/plugin-flock": "0.3.15",
73
+ "@frockbot/plugin-image": "0.3.15",
74
+ "@frockbot/plugin-machine-messages": "0.3.15",
75
+ "@frockbot/plugin-mcp": "0.3.15",
76
+ "@frockbot/plugin-memory": "0.3.15",
77
+ "@frockbot/plugin-package-catalog": "0.3.15",
78
+ "@frockbot/plugin-routines": "0.3.15",
79
+ "@frockbot/plugin-skills": "0.3.15",
80
+ "@frockbot/plugin-subagents": "0.3.15",
81
+ "@frockbot/plugin-user-machine": "0.3.15",
82
+ "@frockbot/protocol": "0.3.15",
82
83
  "cordis": "4.0.0-rc.8",
83
84
  "vue": "3.5.41"
84
85
  },
85
86
  "devDependencies": {
86
87
  "@cloudflare/workers-types": "latest",
87
- "@frockbot/plugin-prompt": "0.3.13",
88
- "@frockbot/plugin-tools": "0.3.13",
88
+ "@frockbot/plugin-prompt": "0.3.15",
89
+ "@frockbot/plugin-tools": "0.3.15",
89
90
  "@types/bun": "1.3.6",
90
91
  "@types/node": "26.2.0",
91
92
  "@vitejs/plugin-vue": "6.0.8",
@@ -25,6 +25,7 @@ import type {
25
25
  MemoryRuntimeHostV1,
26
26
  } from "@frockbot/plugin-memory/agent";
27
27
  import { MemoryStore } from "@frockbot/plugin-memory/store";
28
+ import type { MemoryChunkIndexWriterV1 } from "@frockbot/plugin-memory/chunk-index";
28
29
 
29
30
  /** The Bot and User whose Memory a Turn may read and write. */
30
31
  export interface BotMemoryIdentity {
@@ -50,6 +51,8 @@ export interface BotMemoryEnv {
50
51
  MEMORY_PROJECTS?: MemoryProjectsV1;
51
52
  /** Display names per Bot id, for the `[via …]` tag on a shared fact. */
52
53
  MEMORY_BOT_NAMES?: Readonly<Record<string, string>>;
54
+ /** Bot-scoped vector-id ledger supplied by the Durable Object host. */
55
+ MEMORY_CHUNK_INDEX?: MemoryChunkIndexWriterV1;
53
56
  }
54
57
 
55
58
  /**
@@ -85,5 +88,8 @@ export function createBotMemoryHost(
85
88
  runId: turn.runId,
86
89
  },
87
90
  ...(bindings.MEMORY_PROJECTS ? { projects: bindings.MEMORY_PROJECTS } : {}),
91
+ ...(bindings.MEMORY_CHUNK_INDEX
92
+ ? { chunkIndex: bindings.MEMORY_CHUNK_INDEX }
93
+ : {}),
88
94
  };
89
95
  }
package/src/backend.ts CHANGED
@@ -589,7 +589,8 @@ export interface BotStateEnv {
589
589
  * Bot authoring still compiles; `package_author` then refuses visibly.
590
590
  */
591
591
  PACKAGE_BUNDLER?: PackageBundlerBinding;
592
- MEMORY_INDEX: VectorizeIndex;
592
+ /** Optional in local and workerd hosts, which have no Vectorize simulator. */
593
+ MEMORY_INDEX?: VectorizeIndex;
593
594
  /** The native AI binding consumed through the image Package adapter. */
594
595
  AI?: NativeAiBindingV1;
595
596
  /** The Frock AI Gateway adapter constructed by the Cloudflare host. */
@@ -28,6 +28,19 @@ import {
28
28
  type WebToolActivity,
29
29
  } from "../shared.js";
30
30
  import { ComposerDraftStore } from "./composer-draft.js";
31
+ import {
32
+ applyDictationTailV1,
33
+ voiceButtonLabelV1,
34
+ voiceWaveBarsV1,
35
+ VoiceDictationTranscriptV1,
36
+ type VoiceDictationStateV1,
37
+ } from "./voice-dictation.js";
38
+ import {
39
+ startVoiceMicrophoneV1,
40
+ voiceMicrophoneRefusalV1,
41
+ type VoiceMicrophoneV1,
42
+ } from "./voice-microphone.js";
43
+ import type { VoiceDictationSessionV1 } from "@frockbot/client-core";
31
44
  import {
32
45
  activityTrailBeginV1,
33
46
  activityTrailSampleV1,
@@ -372,6 +385,149 @@ const canSend = computed(
372
385
  */
373
386
  const showStop = computed(() => isRunning.value && !canSend.value);
374
387
 
388
+ /*
389
+ * Dictation (voice plan D4).
390
+ *
391
+ * The send button's slot already switches between Send and Stop, and this is
392
+ * the third thing it holds: with nothing to send and nothing to stop, the
393
+ * button offers to listen instead. While it is listening the slot carries a
394
+ * bin and a send, because those are the only two things a person wants next.
395
+ *
396
+ * Nothing here is a second composer. Speech lands in the same draft the
397
+ * keyboard writes to, through the same `ComposerDraftStore`, and Send is the
398
+ * ordinary `sendMessage` — so a message that is half spoken and half typed
399
+ * behaves exactly like one that is entirely typed, including a refusal that
400
+ * gives the draft back.
401
+ */
402
+ const voiceState = ref<VoiceDictationStateV1>("idle");
403
+ const voiceError = ref<string | undefined>(undefined);
404
+ const voiceBars = ref<number[]>(voiceWaveBarsV1(0, []));
405
+ const voiceTranscript = new VoiceDictationTranscriptV1();
406
+ let voiceSession: VoiceDictationSessionV1 | undefined;
407
+ let voiceMicrophone: VoiceMicrophoneV1 | undefined;
408
+ /** The exact text dictation last wrote, so a typed edit around it survives. */
409
+ let voiceTail = "";
410
+ /** Send was pressed; the last transcript is what we are waiting for. */
411
+ let voiceSendOnFinal = false;
412
+
413
+ const dictating = computed(() => voiceState.value !== "idle");
414
+ const voiceButtonLabel = computed(() => voiceButtonLabelV1(voiceState.value));
415
+ /**
416
+ * The wave button takes the slot only when the slot is otherwise idle: an
417
+ * empty draft, no Turn to stop, and a platform that can actually listen.
418
+ */
419
+ const showVoiceButton = computed(
420
+ () =>
421
+ state.value.voiceAvailable &&
422
+ !dictating.value &&
423
+ !showStop.value &&
424
+ draftText.value.length === 0,
425
+ );
426
+
427
+ function writeDictationIntoDraft(): void {
428
+ const applied = applyDictationTailV1(
429
+ draft.value,
430
+ voiceTail,
431
+ voiceTranscript.text(),
432
+ );
433
+ voiceTail = applied.tail;
434
+ draft.value = applied.draft;
435
+ void nextTick(syncComposerHeight);
436
+ }
437
+
438
+ async function startDictation(): Promise<void> {
439
+ if (dictating.value || !state.value.voiceAvailable) return;
440
+ voiceError.value = undefined;
441
+ voiceTranscript.reset();
442
+ voiceTail = "";
443
+ voiceSendOnFinal = false;
444
+ voiceBars.value = voiceWaveBarsV1(0, []);
445
+ voiceState.value = "starting";
446
+ const session = web.value.openVoiceDictation({
447
+ ready: () => {
448
+ if (voiceState.value === "starting") voiceState.value = "listening";
449
+ },
450
+ delta: (text) => {
451
+ voiceTranscript.delta(text);
452
+ writeDictationIntoDraft();
453
+ },
454
+ transcript: (text) => {
455
+ voiceTranscript.settle(text);
456
+ writeDictationIntoDraft();
457
+ },
458
+ final: () => {
459
+ void completeDictation();
460
+ },
461
+ failed: (message) => {
462
+ voiceError.value = message;
463
+ void stopDictation();
464
+ },
465
+ closed: () => {
466
+ // A socket that goes away mid-capture leaves the draft exactly where it
467
+ // is; the person can still type the rest and send it.
468
+ if (dictating.value) void stopDictation();
469
+ },
470
+ });
471
+ if (!session) {
472
+ voiceState.value = "idle";
473
+ voiceError.value = "Dictation isn't available on this device.";
474
+ return;
475
+ }
476
+ voiceSession = session;
477
+ try {
478
+ voiceMicrophone = await startVoiceMicrophoneV1({
479
+ audio: (pcm16) => session.sendAudio(pcm16),
480
+ level: (value) => {
481
+ voiceBars.value = voiceWaveBarsV1(value, voiceBars.value);
482
+ },
483
+ });
484
+ } catch (error) {
485
+ voiceError.value = voiceMicrophoneRefusalV1(error);
486
+ await stopDictation();
487
+ }
488
+ }
489
+
490
+ /** Everything captured has been transcribed; send it if that is why we stopped. */
491
+ async function completeDictation(): Promise<void> {
492
+ const send = voiceSendOnFinal;
493
+ await stopDictation();
494
+ if (send) await sendMessage();
495
+ }
496
+
497
+ async function stopDictation(): Promise<void> {
498
+ voiceState.value = "idle";
499
+ voiceSendOnFinal = false;
500
+ voiceBars.value = voiceWaveBarsV1(0, []);
501
+ const microphone = voiceMicrophone;
502
+ const session = voiceSession;
503
+ voiceMicrophone = undefined;
504
+ voiceSession = undefined;
505
+ await microphone?.stop();
506
+ session?.close();
507
+ }
508
+
509
+ /** The bin. Discards what was dictated, exactly as D4 says it does. */
510
+ function discardDictation(): void {
511
+ voiceSession?.cancel();
512
+ voiceTranscript.reset();
513
+ voiceTail = "";
514
+ draft.value = "";
515
+ voiceError.value = undefined;
516
+ void stopDictation();
517
+ void nextTick(syncComposerHeight);
518
+ }
519
+
520
+ /**
521
+ * Send, mid-dictation. The audio is committed and the message waits for the
522
+ * last transcript rather than sending half a sentence.
523
+ */
524
+ function sendDictation(): void {
525
+ if (voiceState.value !== "listening") return;
526
+ voiceSendOnFinal = true;
527
+ voiceState.value = "finishing";
528
+ voiceSession?.commit();
529
+ }
530
+
375
531
  /*
376
532
  * Tool activity is internal to the Turn. A Turn that produced only tool calls
377
533
  * shows the Bot avatar while it runs and nothing once it finishes with no
@@ -818,6 +974,9 @@ onBeforeUnmount(() => {
818
974
  window.removeEventListener("hashchange", applySettingsDeepLink);
819
975
  phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
820
976
  window.removeEventListener("keydown", onRootKeydown);
977
+ // A microphone outlives a component that stops drawing it unless it is told
978
+ // not to, and a browser shows the recording indicator for as long as it does.
979
+ void stopDictation();
821
980
  });
822
981
 
823
982
  watch(
@@ -1058,8 +1217,20 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1058
1217
  }
1059
1218
  }
1060
1219
  }
1220
+ if (event.key === "Escape" && dictating.value) {
1221
+ // Escape is the keyboard's bin, the same as it is for the Skill popover.
1222
+ event.preventDefault();
1223
+ discardDictation();
1224
+ return;
1225
+ }
1061
1226
  if (event.key !== "Enter" || event.shiftKey) return;
1062
1227
  event.preventDefault();
1228
+ // Enter means send either way; mid-dictation it commits the audio first so
1229
+ // the last word spoken is in the message.
1230
+ if (dictating.value) {
1231
+ sendDictation();
1232
+ return;
1233
+ }
1063
1234
  void sendMessage();
1064
1235
  }
1065
1236
  </script>
@@ -1492,6 +1663,36 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1492
1663
  >
1493
1664
  {{ draftCounterLabel }}
1494
1665
  </p>
1666
+ <!--
1667
+ The capture animation, and the only place the state is written
1668
+ out. It is level-driven from the microphone: bars that move while
1669
+ the room is silent would say the microphone works when it does
1670
+ not.
1671
+ -->
1672
+ <p
1673
+ v-if="dictating"
1674
+ class="voice-capture"
1675
+ role="status"
1676
+ aria-live="polite"
1677
+ >
1678
+ <span class="voice-wave" aria-hidden="true">
1679
+ <span
1680
+ v-for="(bar, index) in voiceBars"
1681
+ :key="index"
1682
+ class="voice-wave-bar"
1683
+ :style="{ transform: `scaleY(${bar})` }"
1684
+ />
1685
+ </span>
1686
+ <span>{{ voiceButtonLabel }}</span>
1687
+ </p>
1688
+ <!--
1689
+ Why dictation stopped, in the words the server or the browser
1690
+ used. It sits under the draft it could not add to, and the draft
1691
+ itself is untouched.
1692
+ -->
1693
+ <p v-if="voiceError" class="voice-error" role="alert">
1694
+ {{ voiceError }}
1695
+ </p>
1495
1696
  </div>
1496
1697
  <!--
1497
1698
  Start a new conversation. Sits beside the composer because that is
@@ -1506,14 +1707,44 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1506
1707
  :disabled="isRunning"
1507
1708
  @click="web.startConversation()"
1508
1709
  />
1710
+ <!--
1711
+ The send slot, and its four states. Dictation replaces the one
1712
+ button with two, because while it is listening the only two things
1713
+ worth offering are "throw this away" and "that's the message".
1714
+ -->
1715
+ <template v-if="dictating">
1716
+ <UiIconButton
1717
+ class="voice-discard-button"
1718
+ icon="trash"
1719
+ label="Discard dictation"
1720
+ variant="ghost"
1721
+ @click="discardDictation"
1722
+ />
1723
+ <UiIconButton
1724
+ class="voice-send-button"
1725
+ icon="arrow-up"
1726
+ label="Send dictated message"
1727
+ variant="primary"
1728
+ :disabled="voiceState !== 'listening'"
1729
+ @click="sendDictation"
1730
+ />
1731
+ </template>
1509
1732
  <UiIconButton
1510
- v-if="showStop"
1733
+ v-else-if="showStop"
1511
1734
  class="stop-button"
1512
1735
  icon="stop"
1513
1736
  label="Stop generating"
1514
1737
  variant="primary"
1515
1738
  @click="web.stopRun()"
1516
1739
  />
1740
+ <UiIconButton
1741
+ v-else-if="showVoiceButton"
1742
+ class="voice-button"
1743
+ icon="waveform"
1744
+ :label="voiceButtonLabel"
1745
+ variant="primary"
1746
+ @click="startDictation"
1747
+ />
1517
1748
  <UiIconButton
1518
1749
  v-else
1519
1750
  type="submit"
@@ -15,6 +15,7 @@ import {
15
15
  } from "@frockbot/client-core";
16
16
  import { clientSurfaceRegistryKey } from "@frockbot/client-core";
17
17
  import { COMPACTED_ANNOUNCEMENT_TEXT_V1 } from "../compaction.js";
18
+ import { voiceCaptureSupportedV1 } from "./voice-microphone.js";
18
19
  import { readViewerFocusV1, shouldNotifyForBotV1 } from "../focus.js";
19
20
  // Connection mutations use the provider-neutral hosted command contract.
20
21
  import type {
@@ -1417,6 +1418,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1417
1418
  modelSource: "none",
1418
1419
  settingsAvailable: true,
1419
1420
  connectionsAvailable: ctx.transport.connectionsAvailable !== false,
1421
+ voiceAvailable:
1422
+ typeof ctx.transport.openVoiceDictation === "function" &&
1423
+ voiceCaptureSupportedV1(),
1420
1424
  activeBotId: undefined,
1421
1425
  composerContext: undefined,
1422
1426
  transcripts: {
@@ -2878,6 +2882,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2878
2882
  }
2879
2883
  }
2880
2884
  },
2885
+ openVoiceDictation(observer) {
2886
+ return ctx.transport.openVoiceDictation?.(observer);
2887
+ },
2881
2888
  async resumeRun(runId: string): Promise<void> {
2882
2889
  if (!ctx.transport.reconcileRun) {
2883
2890
  web.value.settingsError = "Can't retry this right now.";
@@ -758,6 +758,50 @@
758
758
  color: var(--frock-danger-text);
759
759
  }
760
760
 
761
+ /*
762
+ * Dictation.
763
+ *
764
+ * The capture line sits under the draft it is writing, inside the composer,
765
+ * because the text arriving in the textarea is the thing it is describing. It
766
+ * is a status line rather than an overlay: nothing about the composer moves
767
+ * when it appears beyond the row it adds.
768
+ */
769
+ .voice-capture {
770
+ display: flex;
771
+ margin: 0;
772
+ align-items: center;
773
+ gap: 8px;
774
+ color: var(--frock-text-muted);
775
+ font-size: var(--frock-text-xs);
776
+ }
777
+
778
+ .voice-wave {
779
+ display: flex;
780
+ height: 14px;
781
+ align-items: center;
782
+ gap: 2px;
783
+ }
784
+
785
+ /*
786
+ * Each bar is scaled from the microphone's own level, so the animation stops
787
+ * when the room does. `transform` rather than `height`: it is the one property
788
+ * that animates without a layout pass, and this runs at frame rate.
789
+ */
790
+ .voice-wave-bar {
791
+ width: 2px;
792
+ height: 100%;
793
+ border-radius: 999px;
794
+ background: var(--frock-action-primary);
795
+ transform-origin: center;
796
+ transition: transform var(--frock-motion-fast);
797
+ }
798
+
799
+ .voice-error {
800
+ margin: 0;
801
+ color: var(--frock-danger-text);
802
+ font-size: var(--frock-text-xs);
803
+ }
804
+
761
805
  .skill-chips {
762
806
  display: flex;
763
807
  margin: 0;
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ applyDictationTailV1,
4
+ voiceButtonLabelV1,
5
+ voiceWaveBarsV1,
6
+ VoiceDictationTranscriptV1,
7
+ } from "./voice-dictation.js";
8
+
9
+ describe("writing dictation into a draft somebody may be editing", () => {
10
+ test("appends the first words to an empty draft", () => {
11
+ expect(applyDictationTailV1("", "", "hello")).toEqual({
12
+ draft: "hello",
13
+ tail: "hello",
14
+ });
15
+ });
16
+
17
+ test("grows in place rather than repeating itself", () => {
18
+ const first = applyDictationTailV1("", "", "hello");
19
+ const second = applyDictationTailV1(first.draft, first.tail, "hello there");
20
+ expect(second.draft).toBe("hello there");
21
+ });
22
+
23
+ test("keeps a typed prefix, and a typed suffix, around what is spoken", () => {
24
+ const first = applyDictationTailV1("Note: ", "", "buy milk");
25
+ expect(first.draft).toBe("Note: buy milk");
26
+ // The person types on the end while the next words arrive.
27
+ const edited = `${first.draft} today`;
28
+ const second = applyDictationTailV1(
29
+ edited,
30
+ first.tail,
31
+ "buy milk and bread",
32
+ );
33
+ expect(second.draft).toBe("Note: buy milk and bread today");
34
+ });
35
+
36
+ test("rewrites the last occurrence, so a repeated word moves the right one", () => {
37
+ // "go" was typed, then "go" was dictated. Growing the dictated one must
38
+ // not reach back and rewrite the typed one.
39
+ expect(applyDictationTailV1("go go", "go", "gone")).toEqual({
40
+ draft: "go gone",
41
+ tail: "gone",
42
+ });
43
+ });
44
+
45
+ test("appends rather than fighting when the person deleted what was dictated", () => {
46
+ const first = applyDictationTailV1("", "", "hello");
47
+ const cleared = applyDictationTailV1("", first.tail, "hello there");
48
+ expect(cleared).toEqual({ draft: "hello there", tail: "hello there" });
49
+ });
50
+
51
+ test("never glues two words together", () => {
52
+ expect(applyDictationTailV1("Note:", "", "one").draft).toBe("Note: one");
53
+ expect(applyDictationTailV1("Note: ", "", "one").draft).toBe("Note: one");
54
+ });
55
+ });
56
+
57
+ describe("what has been heard so far", () => {
58
+ test("streams deltas, then lets the finished segment replace them", () => {
59
+ const transcript = new VoiceDictationTranscriptV1();
60
+ expect(transcript.empty()).toBe(true);
61
+ transcript.delta("hello ");
62
+ transcript.delta("their");
63
+ expect(transcript.text()).toBe("hello their");
64
+ transcript.settle("Hello there.");
65
+ expect(transcript.text()).toBe("Hello there.");
66
+ expect(transcript.empty()).toBe(false);
67
+ });
68
+
69
+ test("joins segments with a single space and drops an empty one", () => {
70
+ const transcript = new VoiceDictationTranscriptV1();
71
+ transcript.settle(" One. ");
72
+ transcript.settle("");
73
+ transcript.settle("Two.");
74
+ transcript.delta("thr");
75
+ expect(transcript.text()).toBe("One. Two. thr");
76
+ });
77
+
78
+ test("resets to nothing, so a binned capture leaves no tail behind", () => {
79
+ const transcript = new VoiceDictationTranscriptV1();
80
+ transcript.settle("One.");
81
+ transcript.reset();
82
+ expect(transcript.empty()).toBe(true);
83
+ expect(transcript.text()).toBe("");
84
+ });
85
+ });
86
+
87
+ describe("the composer's dictation chrome", () => {
88
+ test("names each state for the aria label and the tooltip alike", () => {
89
+ expect(voiceButtonLabelV1("idle")).toBe("Dictate a message");
90
+ expect(voiceButtonLabelV1("listening")).toBe("Listening");
91
+ expect(voiceButtonLabelV1("finishing")).toBe("Finishing dictation");
92
+ });
93
+
94
+ test("the wave follows the microphone and never collapses to a line", () => {
95
+ const silent = voiceWaveBarsV1(0, []);
96
+ expect(silent).toHaveLength(4);
97
+ expect(silent.every((bar) => bar >= 0.2)).toBe(true);
98
+ const loud = voiceWaveBarsV1(1, silent);
99
+ expect(loud[0]).toBeCloseTo(1);
100
+ // The level travels along the bars rather than moving all of them at once.
101
+ expect(loud[1]).toBeCloseTo(silent[0]!);
102
+ expect(voiceWaveBarsV1(Number.NaN, silent)[0]).toBeCloseTo(0.2);
103
+ expect(voiceWaveBarsV1(9, silent)[0]).toBeCloseTo(1);
104
+ });
105
+ });
@@ -0,0 +1,137 @@
1
+ // Dictation, as text arriving in a draft somebody may be editing at the same
2
+ // time (voice plan D4).
3
+ //
4
+ // The composer writes what it hears into the textarea rather than into a
5
+ // separate box, so the message is editable while it is being spoken, a
6
+ // rejected send restores it like any other draft, and Send is the ordinary
7
+ // Send. That makes one thing hard and this module is that one thing: knowing
8
+ // which part of the draft dictation put there, when the person is free to
9
+ // type in the middle of it.
10
+ //
11
+ // The answer is a *tail*: the exact text last written on dictation's behalf.
12
+ // Each update replaces the last occurrence of the previous tail with the next
13
+ // one. Delete it, retype around it, paste over it — if the previous tail is
14
+ // no longer in the draft, the new text is appended rather than forced back
15
+ // into a position nobody asked for. Nothing here reads the caret, so it holds
16
+ // on a phone keyboard and a desktop one alike.
17
+
18
+ export type VoiceDictationStateV1 =
19
+ /** No microphone. The send button is the wave button when the draft is empty. */
20
+ | "idle"
21
+ /** Asked for the microphone, or waiting for the upstream to say `ready`. */
22
+ | "starting"
23
+ /** Capturing. Bin and Send have replaced the wave button. */
24
+ | "listening"
25
+ /** Send was pressed; the last of the audio is being transcribed. */
26
+ | "finishing";
27
+
28
+ export interface DictationDraftV1 {
29
+ draft: string;
30
+ tail: string;
31
+ }
32
+
33
+ /** Joins dictation onto a draft without gluing two words together. */
34
+ function joined(head: string, tail: string): string {
35
+ if (!head) return tail;
36
+ if (!tail) return head;
37
+ return /\s$/u.test(head) ? `${head}${tail}` : `${head} ${tail}`;
38
+ }
39
+
40
+ /**
41
+ * Puts `nextTail` where `previousTail` was, or on the end when it has gone.
42
+ *
43
+ * `lastIndexOf`, not `indexOf`: dictating the same short word twice must
44
+ * rewrite the second one.
45
+ */
46
+ export function applyDictationTailV1(
47
+ draft: string,
48
+ previousTail: string,
49
+ nextTail: string,
50
+ ): DictationDraftV1 {
51
+ if (!previousTail) return { draft: joined(draft, nextTail), tail: nextTail };
52
+ const at = draft.lastIndexOf(previousTail);
53
+ if (at < 0) return { draft: joined(draft, nextTail), tail: nextTail };
54
+ const before = draft.slice(0, at);
55
+ const after = draft.slice(at + previousTail.length);
56
+ return { draft: `${before}${nextTail}${after}`, tail: nextTail };
57
+ }
58
+
59
+ /**
60
+ * What has been heard so far: the finished segments, and the deltas of the
61
+ * one still being spoken.
62
+ *
63
+ * A provider streams a segment as deltas and then re-sends it, punctuated and
64
+ * capitalised, as a `completed` transcript. Keeping the two apart is what lets
65
+ * the finished form replace the rough one in place instead of appearing twice.
66
+ */
67
+ export class VoiceDictationTranscriptV1 {
68
+ #settled: string[] = [];
69
+ #pending = "";
70
+
71
+ delta(text: string): void {
72
+ this.#pending += text;
73
+ }
74
+
75
+ /** One finished segment; it replaces the deltas that built it. */
76
+ settle(text: string): void {
77
+ const trimmed = text.trim();
78
+ if (trimmed) this.#settled.push(trimmed);
79
+ this.#pending = "";
80
+ }
81
+
82
+ /** Everything dictated in this session, as one string. */
83
+ text(): string {
84
+ const settled = this.#settled.join(" ");
85
+ const pending = this.#pending.trim();
86
+ if (!settled) return pending;
87
+ if (!pending) return settled;
88
+ return `${settled} ${pending}`;
89
+ }
90
+
91
+ /** True until the first word arrives, so an empty capture sends nothing. */
92
+ empty(): boolean {
93
+ return this.text().length === 0;
94
+ }
95
+
96
+ reset(): void {
97
+ this.#settled = [];
98
+ this.#pending = "";
99
+ }
100
+ }
101
+
102
+ /** The composer's label for each state, so the aria text and the title agree. */
103
+ export function voiceButtonLabelV1(state: VoiceDictationStateV1): string {
104
+ switch (state) {
105
+ case "idle":
106
+ return "Dictate a message";
107
+ case "starting":
108
+ return "Starting dictation";
109
+ case "listening":
110
+ return "Listening";
111
+ case "finishing":
112
+ return "Finishing dictation";
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The scale factor of each bar of the capture animation, newest first.
118
+ *
119
+ * Level-driven rather than time-driven: a wave that moves while the room is
120
+ * silent says the microphone is working when it is not. Each call shifts the
121
+ * previous bars along, so a syllable travels across the control instead of
122
+ * every bar jumping at once.
123
+ *
124
+ * `previous` is the value this function last returned, so the whole animation
125
+ * is one `ref` and one assignment.
126
+ */
127
+ export function voiceWaveBarsV1(
128
+ level: number,
129
+ previous: readonly number[],
130
+ bars = 4,
131
+ ): number[] {
132
+ const clamped = Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0;
133
+ // A floor, so the control never collapses to a line and loses its shape.
134
+ const next = [0.2 + clamped * 0.8, ...previous].slice(0, bars);
135
+ while (next.length < bars) next.push(0.2);
136
+ return next;
137
+ }
@@ -0,0 +1,125 @@
1
+ // The browser's half of dictation: a microphone, resampled to the PCM16 the
2
+ // upstream transcription session expects.
3
+ //
4
+ // The capture itself runs on the audio thread; its source and the reason it is
5
+ // served as a first-party asset are in `voice-worklet.ts`. This module is the
6
+ // graph around it: permission, the context, the silent sink that keeps the
7
+ // node pulled, and the teardown that actually releases the microphone.
8
+ import {
9
+ VOICE_CAPTURE_WORKLET_PATH_V1,
10
+ VOICE_CAPTURE_WORKLET_PROCESSOR_V1,
11
+ } from "./voice-worklet.js";
12
+
13
+ /** What the upstream is told to expect, and therefore what leaves here. */
14
+ export const VOICE_CAPTURE_SAMPLE_RATE_V1 = 24_000;
15
+
16
+ /** Samples per frame at 24 kHz: 32 ms, small enough to feel live. */
17
+ const FRAME_SAMPLES = 768;
18
+
19
+ export interface VoiceMicrophoneV1 {
20
+ stop(): Promise<void>;
21
+ }
22
+
23
+ export interface VoiceMicrophoneOptionsV1 {
24
+ /** One frame of PCM16, little-endian, mono, 24 kHz. */
25
+ audio(pcm16: ArrayBuffer): void;
26
+ /** Peak amplitude of the frame, 0…1, for the capture animation. */
27
+ level(value: number): void;
28
+ }
29
+
30
+ /** False on a platform with no microphone API at all; the button stays hidden. */
31
+ export function voiceCaptureSupportedV1(): boolean {
32
+ return (
33
+ typeof navigator !== "undefined" &&
34
+ typeof navigator.mediaDevices?.getUserMedia === "function" &&
35
+ typeof (globalThis as { AudioContext?: unknown }).AudioContext ===
36
+ "function"
37
+ );
38
+ }
39
+
40
+ /**
41
+ * The refusal a person reads when the browser will not give up the
42
+ * microphone. Named cases only: anything else says what the browser said.
43
+ */
44
+ export function voiceMicrophoneRefusalV1(error: unknown): string {
45
+ const name =
46
+ typeof error === "object" && error !== null && "name" in error
47
+ ? String((error as { name: unknown }).name)
48
+ : "";
49
+ if (name === "NotAllowedError" || name === "SecurityError") {
50
+ return "FrockBot needs permission to use your microphone. Allow it in your browser, then try again.";
51
+ }
52
+ if (name === "NotFoundError" || name === "OverconstrainedError") {
53
+ return "No microphone was found. Plug one in, then try again.";
54
+ }
55
+ return `The microphone couldn't start: ${
56
+ error instanceof Error && error.message ? error.message : "unknown error"
57
+ }`;
58
+ }
59
+
60
+ export async function startVoiceMicrophoneV1(
61
+ options: VoiceMicrophoneOptionsV1,
62
+ ): Promise<VoiceMicrophoneV1> {
63
+ const stream = await navigator.mediaDevices.getUserMedia({
64
+ audio: {
65
+ channelCount: 1,
66
+ echoCancellation: true,
67
+ noiseSuppression: true,
68
+ autoGainControl: true,
69
+ },
70
+ });
71
+ const context = new AudioContext();
72
+ let node: AudioWorkletNode | undefined;
73
+ let source: MediaStreamAudioSourceNode | undefined;
74
+ let silence: GainNode | undefined;
75
+ try {
76
+ // Same-origin, so `script-src 'self'` admits it. See `voice-worklet.ts`.
77
+ await context.audioWorklet.addModule(VOICE_CAPTURE_WORKLET_PATH_V1);
78
+ node = new AudioWorkletNode(context, VOICE_CAPTURE_WORKLET_PROCESSOR_V1, {
79
+ numberOfInputs: 1,
80
+ // One silent output, connected below. A graph is pulled from the
81
+ // destination, so a node with no path to it is never asked to process
82
+ // and the microphone produces nothing at all — silently.
83
+ numberOfOutputs: 1,
84
+ outputChannelCount: [1],
85
+ processorOptions: {
86
+ targetRate: VOICE_CAPTURE_SAMPLE_RATE_V1,
87
+ frameSamples: FRAME_SAMPLES,
88
+ },
89
+ });
90
+ node.port.onmessage = (event: MessageEvent) => {
91
+ const message = event.data as { pcm?: ArrayBuffer; level?: number };
92
+ if (message.pcm) options.audio(message.pcm);
93
+ if (typeof message.level === "number") options.level(message.level);
94
+ };
95
+ source = context.createMediaStreamSource(stream);
96
+ source.connect(node);
97
+ // Silenced at the sink rather than left unconnected: the person must not
98
+ // hear themselves, and the node must still be pulled.
99
+ silence = context.createGain();
100
+ silence.gain.value = 0;
101
+ node.connect(silence);
102
+ silence.connect(context.destination);
103
+ // A suspended context produces silence and no error at all; Safari hands
104
+ // one back whenever the gesture that opened it has already finished.
105
+ if (context.state === "suspended") await context.resume();
106
+ } catch (error) {
107
+ for (const track of stream.getTracks()) track.stop();
108
+ await context.close().catch(() => undefined);
109
+ throw error;
110
+ }
111
+
112
+ let stopped = false;
113
+ return {
114
+ async stop() {
115
+ if (stopped) return;
116
+ stopped = true;
117
+ if (node) node.port.onmessage = null;
118
+ source?.disconnect();
119
+ node?.disconnect();
120
+ silence?.disconnect();
121
+ for (const track of stream.getTracks()) track.stop();
122
+ await context.close().catch(() => undefined);
123
+ },
124
+ };
125
+ }
@@ -0,0 +1,75 @@
1
+ // The dictation capture worklet, and the path the app serves it from.
2
+ //
3
+ // It is a first-party asset on the app's own origin rather than a blob URL,
4
+ // and that is a security decision, not a packaging one: the hosted client is
5
+ // served under `script-src 'self'`, which a `blob:` module does not satisfy —
6
+ // `context.audioWorklet.addModule(blobUrl)` fails with "Unable to load a
7
+ // worklet's module". Widening the policy to admit blob scripts everywhere, to
8
+ // load one 40-line file, is the wrong trade. So the source lives here as a
9
+ // string, the application Worker answers `GET` on the path below with it
10
+ // (`apps/cloudflare/src/user-application.ts`), and the policy is untouched.
11
+ //
12
+ // Its own module so the Worker can serve the source without pulling the
13
+ // browser-only microphone plumbing in beside it.
14
+
15
+ /** Where the application Worker serves {@link VOICE_CAPTURE_WORKLET_SOURCE_V1}. */
16
+ export const VOICE_CAPTURE_WORKLET_PATH_V1 = "/voice-capture-worklet.js";
17
+
18
+ /** The processor's registered name, shared by the source and the node. */
19
+ export const VOICE_CAPTURE_WORKLET_PROCESSOR_V1 = "frock-voice-capture";
20
+
21
+ /**
22
+ * Microphone audio, decimated to the target rate and framed as PCM16.
23
+ *
24
+ * On the audio thread rather than the main one, because the alternative
25
+ * (`ScriptProcessorNode`) shares the thread with Vue's renderer and drops
26
+ * audio exactly when the composer is busiest — while the draft it is writing
27
+ * is being re-laid out.
28
+ *
29
+ * The rate is reached by decimating whatever the context gives us rather than
30
+ * by asking for it: iOS Safari ignores `new AudioContext({ sampleRate })` and
31
+ * hands back 48 kHz regardless, and audio at the wrong rate transcribes as
32
+ * gibberish rather than failing.
33
+ */
34
+ export const VOICE_CAPTURE_WORKLET_SOURCE_V1 = `
35
+ class FrockVoiceCapture extends AudioWorkletProcessor {
36
+ constructor(options) {
37
+ super();
38
+ this.target = options.processorOptions.targetRate;
39
+ this.frame = options.processorOptions.frameSamples;
40
+ this.buffer = new Float32Array(this.frame);
41
+ this.filled = 0;
42
+ this.position = 0;
43
+ }
44
+ process(inputs) {
45
+ const channel = inputs[0] && inputs[0][0];
46
+ if (!channel) return true;
47
+ const step = sampleRate / this.target;
48
+ let peak = 0;
49
+ for (let index = 0; index < channel.length; index += 1) {
50
+ const value = channel[index];
51
+ const magnitude = value < 0 ? -value : value;
52
+ if (magnitude > peak) peak = magnitude;
53
+ }
54
+ // Decimation: walk the block at a fractional step, carrying the remainder
55
+ // across blocks so no drift accumulates. Enough for speech, and cheap.
56
+ while (this.position < channel.length) {
57
+ this.buffer[this.filled] = channel[Math.floor(this.position)];
58
+ this.position += step;
59
+ this.filled += 1;
60
+ if (this.filled === this.frame) {
61
+ const pcm = new Int16Array(this.frame);
62
+ for (let sample = 0; sample < this.frame; sample += 1) {
63
+ const clamped = Math.max(-1, Math.min(1, this.buffer[sample]));
64
+ pcm[sample] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
65
+ }
66
+ this.port.postMessage({ pcm: pcm.buffer, level: peak }, [pcm.buffer]);
67
+ this.filled = 0;
68
+ }
69
+ }
70
+ this.position -= channel.length;
71
+ return true;
72
+ }
73
+ }
74
+ registerProcessor("${VOICE_CAPTURE_WORKLET_PROCESSOR_V1}", FrockVoiceCapture);
75
+ `;
@@ -3,6 +3,7 @@ import type { SessionEvent } from "@frockbot/kernel-contracts";
3
3
  import { MODEL_FIRST_BYTE_DEADLINE_REASON_V1 } from "@frockbot/kernel-contracts";
4
4
  import {
5
5
  knownFailureCopyV1,
6
+ MODEL_PROVIDER_FAILURE_COPY_V1,
6
7
  RUN_FAILURE_COPY_V1,
7
8
  RUN_FAILURE_FALLBACK_COPY_V1,
8
9
  runFailureCopyV1,
@@ -75,6 +76,14 @@ function failedRun(failure: string, events: SessionEvent[]): StoredRun {
75
76
  describe("runFailureCopyV1", () => {
76
77
  test("every mapped sentence is written for a person", () => {
77
78
  for (const copy of Object.values(RUN_FAILURE_COPY_V1)) assertPlainV1(copy);
79
+ expect(Object.keys(MODEL_PROVIDER_FAILURE_COPY_V1).sort()).toEqual([
80
+ "permanent",
81
+ "transient",
82
+ "unknown",
83
+ ]);
84
+ for (const copy of Object.values(MODEL_PROVIDER_FAILURE_COPY_V1)) {
85
+ assertPlainV1(copy);
86
+ }
78
87
  assertPlainV1(RUN_FAILURE_FALLBACK_COPY_V1);
79
88
  for (const reason of USER_FACING_FAILURE_REASONS_V1) assertPlainV1(reason);
80
89
  });
@@ -1,4 +1,8 @@
1
- import type { SessionEvent, TurnOutcome } from "@frockbot/kernel-contracts";
1
+ import type {
2
+ ModelProviderFailureClassV1,
3
+ SessionEvent,
4
+ TurnOutcome,
5
+ } from "@frockbot/kernel-contracts";
2
6
  import {
3
7
  MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
4
8
  MODEL_IDLE_DEADLINE_REASON_V1,
@@ -59,6 +63,16 @@ export const RUN_FAILURE_COPY_V1: Record<TurnOutcome, string> = {
59
63
  "tool-error": "Something the Bot was using didn't work. Try again.",
60
64
  };
61
65
 
66
+ /** Every provider class reaches the same intentionally plain model-error copy. */
67
+ export const MODEL_PROVIDER_FAILURE_COPY_V1: Record<
68
+ ModelProviderFailureClassV1,
69
+ string
70
+ > = {
71
+ transient: RUN_FAILURE_COPY_V1["model-error"],
72
+ permanent: RUN_FAILURE_COPY_V1["model-error"],
73
+ unknown: RUN_FAILURE_COPY_V1["model-error"],
74
+ };
75
+
62
76
  /** What a Turn says when nothing more specific is known about how it ended. */
63
77
  export const RUN_FAILURE_FALLBACK_COPY_V1 =
64
78
  "This Bot couldn't finish its reply. Try again.";
package/src/shared.ts CHANGED
@@ -22,6 +22,10 @@ import type {
22
22
  SendToUserPayloadV1,
23
23
  SkillRefV1,
24
24
  } from "@frockbot/kernel-contracts";
25
+ import type {
26
+ VoiceDictationObserverV1,
27
+ VoiceDictationSessionV1,
28
+ } from "@frockbot/client-core";
25
29
  import type { McpServerStatusViewV1 } from "@frockbot/plugin-mcp/records";
26
30
  import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
27
31
  import type { ClientSkillCatalogEntryV1 } from "./skill-protocol.js";
@@ -256,6 +260,12 @@ export interface FrockBotWebData {
256
260
  modelSource: "bot" | "default" | "none";
257
261
  settingsAvailable: boolean;
258
262
  connectionsAvailable: boolean;
263
+ /**
264
+ * False when this platform cannot dictate — no transport socket, or a
265
+ * browser with no microphone API. The composer's send button then never
266
+ * changes shape and nothing about it moves.
267
+ */
268
+ voiceAvailable: boolean;
259
269
  activeBotId?: string;
260
270
  composerContext?: unknown;
261
271
  messages: WebChatMessage[];
@@ -511,6 +521,13 @@ export interface FrockBotWebData {
511
521
  skills?: readonly SkillRefV1[],
512
522
  ): Promise<SendPromptResult>;
513
523
  resumeRun(runId: string): Promise<void>;
524
+ /**
525
+ * Opens one dictation session (voice plan D2). `undefined` on a platform
526
+ * whose transport cannot, which is what `voiceAvailable` reports up front.
527
+ */
528
+ openVoiceDictation(
529
+ observer: VoiceDictationObserverV1,
530
+ ): VoiceDictationSessionV1 | undefined;
514
531
  /** Sends the durable Stop command for the observed active run. */
515
532
  stopRun(): Promise<void>;
516
533
  /** Detaches the local observer only; admitted work stays durable. */