@iinm/plain-agent 1.11.9 → 1.12.1

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
@@ -3,7 +3,7 @@
3
3
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iinm/plain-agent)
4
4
  [![npm version](https://img.shields.io/npm/v/@iinm/plain-agent)](https://www.npmjs.com/package/@iinm/plain-agent)
5
5
  [![install size](https://packagephobia.com/badge?p=@iinm/plain-agent)](https://packagephobia.com/result?p=@iinm/plain-agent)
6
- [![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.11.9)](https://socket.dev/npm/package/@iinm/plain-agent)
6
+ [![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.12.1)](https://socket.dev/npm/package/@iinm/plain-agent)
7
7
  [![CodeQL](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql)
8
8
 
9
9
  A lightweight terminal-based coding agent focused on safety and low token cost
@@ -25,7 +25,6 @@ A lightweight terminal-based coding agent focused on safety and low token cost
25
25
  - [Prompts](#prompts)
26
26
  - [Subagents](#subagents)
27
27
  - [Claude Code Plugin Support](#claude-code-plugin-support)
28
- - [Voice Input](#voice-input)
29
28
  - [Appendix: Creating Least-Privilege Users for Cloud Providers](#appendix-creating-least-privilege-users-for-cloud-providers)
30
29
  - [Developer Notes](#developer-notes)
31
30
 
@@ -848,52 +847,6 @@ Example:
848
847
  plain install-claude-code-plugins
849
848
  ```
850
849
 
851
- ## Voice Input
852
-
853
- Press **Ctrl-O** to start recording, then press it again to stop. Partial transcripts are inserted into the prompt as you speak, so you can edit and send them like regular text.
854
-
855
- ### Requirements
856
-
857
- - A recording command on `PATH`: `arecord`, `sox`, or `ffmpeg`.
858
- - An API key for the chosen provider.
859
- - Your host must have microphone access.
860
-
861
- ### Providers
862
-
863
- **OpenAI Realtime**
864
-
865
- ```js
866
- // ~/.config/plain-agent/config.local.json
867
- {
868
- "voiceInput": {
869
- "provider": "openai",
870
- "apiKey": "<OPENAI_API_KEY>"
871
- // "model": "gpt-4o-transcribe", // or "gpt-4o-mini-transcribe", "whisper-1"
872
- // "language": "ja" // ISO-639-1 code. Improves accuracy and latency.
873
- }
874
- }
875
- ```
876
-
877
- **Gemini Live**
878
-
879
- ```js
880
- // ~/.config/plain-agent/config.local.json
881
- {
882
- "voiceInput": {
883
- "provider": "gemini",
884
- "apiKey": "<GEMINI_API_KEY>"
885
- // "model": "gemini-3.1-flash-live-preview",
886
- // "language": "ja"
887
- }
888
- }
889
- ```
890
-
891
- ### Options
892
-
893
- - `toggleKey` — Rebind the toggle key. Accepts `"ctrl-<char>"` where `<char>`
894
- is a letter (a-z) or one of `[ \ ] ^ _`. Defaults to `"ctrl-o"`.
895
- - `recorder` — Override automatic recorder detection, e.g. `{ "command": "sox", "args": ["-q", "-d", "-b", "16", "-c", "1", "-r", "24000", "-e", "signed-integer", "-t", "raw", "-"] }`. It must write raw 16-bit little-endian mono PCM to stdout at 24 kHz (OpenAI) or 16 kHz (Gemini).
896
-
897
850
  ## Appendix: Creating Least-Privilege Users for Cloud Providers
898
851
 
899
852
  <details>
@@ -5,9 +5,15 @@
5
5
  "patterns": [
6
6
  {
7
7
  "toolName": "exec_command",
8
- "input": { "command": { "$regex": "^(find|grep)$" } },
8
+ "input": { "command": "find" },
9
9
  "action": "deny",
10
- "reason": "Use rg or fd instead"
10
+ "reason": "Use fd instead; fd respects .gitignore by default"
11
+ },
12
+ {
13
+ "toolName": "exec_command",
14
+ "input": { "command": "grep" },
15
+ "action": "deny",
16
+ "reason": "Use rg instead; rg respects .gitignore by default"
11
17
  },
12
18
  {
13
19
  "toolName": "exec_command",
@@ -146,6 +152,16 @@
146
152
  }
147
153
  ],
148
154
  "tests": [
155
+ {
156
+ "desc": "find should be denied",
157
+ "toolUse": { "toolName": "exec_command", "input": { "command": "find" } },
158
+ "expectedAction": "deny"
159
+ },
160
+ {
161
+ "desc": "grep should be denied",
162
+ "toolUse": { "toolName": "exec_command", "input": { "command": "grep" } },
163
+ "expectedAction": "deny"
164
+ },
149
165
  {
150
166
  "desc": "ls should be allowed",
151
167
  "toolUse": { "toolName": "exec_command", "input": { "command": "ls" } },
@@ -1527,29 +1543,6 @@
1527
1543
  }
1528
1544
  }
1529
1545
  },
1530
- {
1531
- "name": "glm-5.2",
1532
- "variant": "novita",
1533
- "platform": {
1534
- "name": "openai-compatible",
1535
- "variant": "novita"
1536
- },
1537
- "model": {
1538
- "format": "openai-messages",
1539
- "config": {
1540
- "model": "zai-org/glm-5.2"
1541
- }
1542
- },
1543
- "cost": {
1544
- "currency": "USD",
1545
- "unit": "1M",
1546
- "prices": {
1547
- "prompt_tokens": 1.4,
1548
- "prompt_tokens_details.cached_tokens": -1.14,
1549
- "completion_tokens": 4.4
1550
- }
1551
- }
1552
- },
1553
1546
 
1554
1547
  {
1555
1548
  "name": "kimi-k2.6",
@@ -1589,53 +1582,7 @@
1589
1582
  }
1590
1583
  }
1591
1584
  },
1592
- {
1593
- "name": "kimi-k2.7-code",
1594
- "variant": "novita",
1595
- "platform": {
1596
- "name": "openai-compatible",
1597
- "variant": "novita"
1598
- },
1599
- "model": {
1600
- "format": "openai-messages",
1601
- "config": {
1602
- "model": "moonshotai/kimi-k2.7-code"
1603
- }
1604
- },
1605
- "cost": {
1606
- "currency": "USD",
1607
- "unit": "1M",
1608
- "prices": {
1609
- "prompt_tokens": 0.95,
1610
- "prompt_tokens_details.cached_tokens": -0.76,
1611
- "completion_tokens": 4
1612
- }
1613
- }
1614
- },
1615
1585
 
1616
- {
1617
- "name": "deepseek-v4-pro",
1618
- "variant": "novita",
1619
- "platform": {
1620
- "name": "openai-compatible",
1621
- "variant": "novita"
1622
- },
1623
- "model": {
1624
- "format": "openai-messages",
1625
- "config": {
1626
- "model": "deepseek/deepseek-v4-pro"
1627
- }
1628
- },
1629
- "cost": {
1630
- "currency": "USD",
1631
- "unit": "1M",
1632
- "prices": {
1633
- "prompt_tokens": 1.74,
1634
- "prompt_tokens_details.cached_tokens": -1.595,
1635
- "completion_tokens": 3.48
1636
- }
1637
- }
1638
- },
1639
1586
  {
1640
1587
  "name": "deepseek-v4-pro",
1641
1588
  "variant": "fireworks",
@@ -1698,29 +1645,6 @@
1698
1645
  }
1699
1646
  }
1700
1647
  },
1701
- {
1702
- "name": "minimax-m3",
1703
- "variant": "novita",
1704
- "platform": {
1705
- "name": "openai-compatible",
1706
- "variant": "novita"
1707
- },
1708
- "model": {
1709
- "format": "openai-messages",
1710
- "config": {
1711
- "model": "minimax/minimax-m3"
1712
- }
1713
- },
1714
- "cost": {
1715
- "currency": "USD",
1716
- "unit": "1M",
1717
- "prices": {
1718
- "prompt_tokens": 0.3,
1719
- "prompt_tokens_details.cached_tokens": -0.24,
1720
- "completion_tokens": 1.2
1721
- }
1722
- }
1723
- },
1724
1648
 
1725
1649
  {
1726
1650
  "name": "qwen3.7-plus",
@@ -1745,29 +1669,6 @@
1745
1669
  }
1746
1670
  }
1747
1671
  },
1748
- {
1749
- "name": "qwen3.7-max",
1750
- "variant": "novita",
1751
- "platform": {
1752
- "name": "openai-compatible",
1753
- "variant": "novita"
1754
- },
1755
- "model": {
1756
- "format": "openai-messages",
1757
- "config": {
1758
- "model": "qwen/qwen3.7-max"
1759
- }
1760
- },
1761
- "cost": {
1762
- "currency": "USD",
1763
- "unit": "1M",
1764
- "prices": {
1765
- "prompt_tokens": 1.5625,
1766
- "prompt_tokens_details.cached_tokens": -1.4375,
1767
- "completion_tokens": 3.75
1768
- }
1769
- }
1770
- },
1771
1672
 
1772
1673
  {
1773
1674
  "name": "nova-2-lite",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.11.9",
3
+ "version": "1.12.1",
4
4
  "description": "A lightweight terminal-based coding agent focused on safety and low token cost",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,8 +2,6 @@
2
2
  * @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "../agent"
3
3
  * @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs"
4
4
  * @import { Tool, SandboxModeProvider } from "../tool"
5
- * @import { VoiceInputConfig } from "../voice/input.mjs"
6
- * @import { VoiceSession } from "../voice/session.mjs"
7
5
  */
8
6
 
9
7
  import readline from "node:readline";
@@ -11,8 +9,6 @@ import { styleText } from "node:util";
11
9
  import { appendUsageRecord, buildUsageRecord } from "../usageStore.mjs";
12
10
  import { createSequentialExecutor } from "../utils/createSequentialExecutor.mjs";
13
11
  import { notify } from "../utils/notify.mjs";
14
- import { startVoiceSession } from "../voice/input.mjs";
15
- import { parseVoiceToggleKey } from "../voice/toggleKey.mjs";
16
12
  import { createCommandHandler } from "./commands.mjs";
17
13
  import { createCompleter, SLASH_COMMANDS } from "./completer.mjs";
18
14
  import {
@@ -21,7 +17,6 @@ import {
21
17
  printMessage,
22
18
  } from "./formatter.mjs";
23
19
  import { createInterruptTransform } from "./interruptTransform.mjs";
24
- import { createMuteTransform } from "./muteTransform.mjs";
25
20
  import { createPasteHandler } from "./pasteTransform.mjs";
26
21
  import { createStreamFormatter } from "./streamFormatter.mjs";
27
22
 
@@ -67,7 +62,6 @@ const HELP_MESSAGE = [
67
62
  * @property {boolean} sandbox
68
63
  * @property {() => Promise<void>} onStop
69
64
  * @property {ClaudeCodePlugin[]} [claudeCodePlugins]
70
- * @property {VoiceInputConfig} [voiceInput]
71
65
  * @property {Tool & SandboxModeProvider} [execCommandTool]
72
66
  */
73
67
 
@@ -112,7 +106,6 @@ export function startInteractiveSession({
112
106
  sandbox,
113
107
  onStop,
114
108
  claudeCodePlugins,
115
- voiceInput,
116
109
  execCommandTool,
117
110
  }) {
118
111
  /** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string, toolSpinnerIndex: number, toolSpinnerLastTime: number }} */
@@ -127,19 +120,9 @@ export function startInteractiveSession({
127
120
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
128
121
  const SPINNER_INTERVAL_MS = 80;
129
122
 
130
- /**
131
- * Active voice input session, or null when not recording.
132
- * @type {{ session: VoiceSession, startCursor: number, transcriptLength: number } | null}
133
- */
134
- let voice = null;
135
-
136
123
  // Create the stream buffer instance for this session
137
124
  const streamBuffer = createStreamBuffer();
138
125
 
139
- // Parse the voice toggle key once at startup so misconfiguration fails
140
- // loudly instead of silently falling back.
141
- const voiceToggle = parseVoiceToggleKey(voiceInput?.toggleKey);
142
-
143
126
  const getCliPrompt = (subagentName = "", flashMessage = "") =>
144
127
  [
145
128
  "",
@@ -198,100 +181,7 @@ export function startInteractiveSession({
198
181
  cli.prompt();
199
182
  };
200
183
 
201
- const stopVoiceSession = async () => {
202
- if (!voice) return;
203
- const current = voice;
204
- voice = null;
205
- await current.session.stop();
206
- cli.setPrompt(currentCliPrompt);
207
- // @ts-expect-error - internal property
208
- cli._refreshLine?.();
209
- };
210
-
211
- const handleVoiceToggle = () => {
212
- // Ignore while the agent is working.
213
- if (!state.turn) return;
214
-
215
- if (voice) {
216
- stopVoiceSession();
217
- return;
218
- }
219
-
220
- if (!voiceInput) {
221
- cli.setPrompt(
222
- getCliPrompt(
223
- state.subagentName,
224
- styleText(
225
- "yellow",
226
- `Voice input not configured. Set \`voiceInput\` in your config to enable ${voiceToggle.label}.`,
227
- ),
228
- ),
229
- );
230
- cli.prompt(true);
231
- return;
232
- }
233
-
234
- const startCursor = cli.cursor;
235
- const session = startVoiceSession({
236
- config: voiceInput,
237
- callbacks: {
238
- onTranscript: (delta) => {
239
- if (!voice) return;
240
- const insertAt = voice.startCursor + voice.transcriptLength;
241
- // Insert delta at the recording's insertion point. User input is
242
- // swallowed while recording, so the buffer around `insertAt` is
243
- // stable.
244
- const before = cli.line.slice(0, insertAt);
245
- const after = cli.line.slice(insertAt);
246
- // `line` and `cursor` are declared readonly in the Node typings but
247
- // are writable at runtime — the existing code already patches
248
- // `_refreshLine` in the same way.
249
- const mutableCli = /** @type {{ line: string, cursor: number }} */ (
250
- /** @type {unknown} */ (cli)
251
- );
252
- mutableCli.line = before + delta + after;
253
- mutableCli.cursor = insertAt + delta.length;
254
- voice.transcriptLength += delta.length;
255
- // @ts-expect-error - internal property
256
- cli._refreshLine?.();
257
- },
258
- onError: (err) => {
259
- voice = null;
260
- cli.setPrompt(
261
- getCliPrompt(
262
- state.subagentName,
263
- styleText("red", `Voice input error: ${err.message}`),
264
- ),
265
- );
266
- cli.prompt(true);
267
- },
268
- onClose: () => {
269
- if (!voice) return;
270
- voice = null;
271
- cli.setPrompt(currentCliPrompt);
272
- // @ts-expect-error - internal property
273
- cli._refreshLine?.();
274
- },
275
- },
276
- });
277
- voice = { session, startCursor, transcriptLength: 0 };
278
- cli.setPrompt(
279
- getCliPrompt(
280
- state.subagentName,
281
- styleText(["red", "bold"], `● REC (${voiceToggle.label} to stop)`),
282
- ),
283
- );
284
- // @ts-expect-error - internal property
285
- cli._refreshLine?.();
286
- };
287
-
288
184
  const handleCtrlC = () => {
289
- // Stop voice recording first if active.
290
- if (voice) {
291
- stopVoiceSession();
292
- return;
293
- }
294
-
295
185
  // Agent turn: pause auto-approve; do not clear input.
296
186
  if (!state.turn) {
297
187
  agentCommands.pauseAutoApprove();
@@ -347,20 +237,14 @@ export function startInteractiveSession({
347
237
  };
348
238
 
349
239
  // Pre-readline pipeline:
350
- // stdin -> interrupt (Ctrl-C / Ctrl-D) -> mute (voice recording) -> paste (bracketed paste) -> readline
240
+ // stdin -> interrupt (Ctrl-C / Ctrl-D) -> paste (bracketed paste) -> readline
351
241
  const interrupt = createInterruptTransform({
352
242
  onCtrlC: handleCtrlC,
353
243
  onCtrlD: handleCtrlD,
354
- onVoiceToggle: handleVoiceToggle,
355
- voiceToggleByte: voiceToggle.byte,
356
244
  });
357
- // While a voice session is recording, swallow all stdin bytes other than
358
- // Ctrl-C / Ctrl-D / the voice toggle key so transcript insertion stays
359
- // consistent.
360
- const mute = createMuteTransform({ isMuted: () => voice !== null });
361
245
  const paste = createPasteHandler();
362
246
 
363
- process.stdin.pipe(interrupt).pipe(mute).pipe(paste.transform);
247
+ process.stdin.pipe(interrupt).pipe(paste.transform);
364
248
 
365
249
  // Enable bracketed paste mode
366
250
  if (process.stdout.isTTY) {
@@ -1,31 +1,21 @@
1
1
  import { Transform } from "node:stream";
2
2
 
3
3
  /**
4
- * Create a Transform that intercepts Ctrl-C (0x03), Ctrl-D (0x04), and an
5
- * optional "voice toggle" byte (default Ctrl-O, 0x0f). When one of those
6
- * bytes is seen anywhere in a chunk, the corresponding callback is invoked
7
- * and the entire chunk is dropped so that downstream consumers (e.g.
8
- * readline) never observe it. All other input flows through unchanged.
4
+ * Create a Transform that intercepts Ctrl-C (0x03) and Ctrl-D (0x04).
5
+ * When one of those bytes is seen anywhere in a chunk, the corresponding
6
+ * callback is invoked and the entire chunk is dropped so that downstream
7
+ * consumers (e.g. readline) never observe it. All other input flows
8
+ * through unchanged.
9
9
  *
10
10
  * Priority when multiple handled bytes appear in the same chunk:
11
- * Ctrl-C > Ctrl-D > voice toggle.
11
+ * Ctrl-C > Ctrl-D.
12
12
  *
13
13
  * @param {object} handlers
14
14
  * @param {() => void} handlers.onCtrlC - Called when Ctrl-C is detected
15
15
  * @param {() => void} handlers.onCtrlD - Called when Ctrl-D is detected
16
- * @param {() => void} [handlers.onVoiceToggle]
17
- * Called when the voice toggle byte is detected.
18
- * @param {number} [handlers.voiceToggleByte]
19
- * Byte value for the voice toggle key. Defaults to 0x0f (Ctrl-O).
20
16
  * @returns {Transform}
21
17
  */
22
- export function createInterruptTransform({
23
- onCtrlC,
24
- onCtrlD,
25
- onVoiceToggle,
26
- voiceToggleByte = 0x0f,
27
- }) {
28
- const voiceToggleChar = String.fromCharCode(voiceToggleByte);
18
+ export function createInterruptTransform({ onCtrlC, onCtrlD }) {
29
19
  return new Transform({
30
20
  transform(chunk, _encoding, callback) {
31
21
  const data = chunk.toString("utf8");
@@ -39,11 +29,6 @@ export function createInterruptTransform({
39
29
  callback();
40
30
  return;
41
31
  }
42
- if (onVoiceToggle && data.includes(voiceToggleChar)) {
43
- onVoiceToggle();
44
- callback();
45
- return;
46
- }
47
32
  this.push(chunk);
48
33
  callback();
49
34
  },
package/src/config.d.ts CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  WebSearchToolGeminiOptions,
11
11
  WebSearchToolGeminiVertexAIOptions,
12
12
  } from "./tools/webSearch.mjs";
13
- import { VoiceInputConfig } from "./voice/input.mjs";
14
13
 
15
14
  /**
16
15
  * JSON-serializable webFetch configuration.
@@ -88,7 +87,6 @@ export type AppConfig = {
88
87
  };
89
88
  mcpServers?: Record<string, MCPServerConfig>;
90
89
  notifyCmd?: { command: string; args?: string[] };
91
- voiceInput?: VoiceInputConfig;
92
90
  claudeCodePlugins?: ClaudeCodePluginRepo[];
93
91
  };
94
92
 
package/src/config.mjs CHANGED
@@ -129,9 +129,6 @@ export async function loadAppConfig(options = {}) {
129
129
  ...(merged.claudeCodePlugins ?? []),
130
130
  ...(config.claudeCodePlugins ?? []),
131
131
  ],
132
- voiceInput: config.voiceInput
133
- ? { ...(merged.voiceInput ?? {}), ...config.voiceInput }
134
- : merged.voiceInput,
135
132
  };
136
133
  }
137
134
 
package/src/main.mjs CHANGED
@@ -447,7 +447,6 @@ export async function main(argv = process.argv) {
447
447
  execCommandTool,
448
448
  notifyCmd: appConfig.notifyCmd,
449
449
  claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
450
- voiceInput: appConfig.voiceInput,
451
450
  });
452
451
  }
453
452
  }
@@ -1,26 +0,0 @@
1
- import { Transform } from "node:stream";
2
-
3
- /**
4
- * Create a Transform that swallows all chunks while `isMuted()` returns true,
5
- * and passes them through unchanged while it returns false.
6
- *
7
- * Intended to sit between `createInterruptTransform` and the paste handler so
8
- * that callers can fully silence regular stdin input during special modes
9
- * (e.g. while a voice input session is recording) without coupling that
10
- * concern to the interrupt-detection logic.
11
- *
12
- * @param {object} options
13
- * @param {() => boolean} options.isMuted
14
- * Called for each incoming chunk; when true the chunk is dropped.
15
- * @returns {Transform}
16
- */
17
- export function createMuteTransform({ isMuted }) {
18
- return new Transform({
19
- transform(chunk, _encoding, callback) {
20
- if (!isMuted()) {
21
- this.push(chunk);
22
- }
23
- callback();
24
- },
25
- });
26
- }
@@ -1,102 +0,0 @@
1
- import { isObjectLike, startWebSocketVoiceSession } from "./session.mjs";
2
-
3
- /**
4
- * @import { VoiceProviderHooks, VoiceRecorderConfig, VoiceSession, VoiceSessionCallbacks } from "./session.mjs"
5
- */
6
-
7
- /**
8
- * @typedef {Object} VoiceInputGeminiConfig
9
- * @property {"gemini"} provider
10
- * @property {string} apiKey
11
- * @property {string} [model] - Defaults to "gemini-3.1-flash-live-preview".
12
- * @property {string} [language] - ISO-639-1 code (e.g. "ja", "en"). Passed to the model as a system instruction since Gemini Live has no native language hint for input transcription.
13
- * @property {string} [baseURL]
14
- * @property {VoiceRecorderConfig} [recorder]
15
- * @property {string} [toggleKey]
16
- */
17
-
18
- const GEMINI_DEFAULT_MODEL = "gemini-3.1-flash-live-preview";
19
- const GEMINI_DEFAULT_WS =
20
- "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent";
21
- const GEMINI_SAMPLE_RATE = 16000;
22
- const GEMINI_LABEL = "Gemini Live";
23
-
24
- /**
25
- * Start a voice input session backed by the Gemini Live BidiGenerateContent
26
- * WebSocket. Spawns a recorder, streams PCM as base64 JSON messages, and
27
- * forwards transcript deltas via `onTranscript`.
28
- *
29
- * Gemini Live was designed for voice agents, not pure STT, so the setup
30
- * message forces `maxOutputTokens: 1` and disables thinking on 2.5 models
31
- * to minimise wasted audio output.
32
- *
33
- * @param {object} options
34
- * @param {VoiceInputGeminiConfig} options.config
35
- * @param {VoiceSessionCallbacks} options.callbacks
36
- * @returns {VoiceSession}
37
- */
38
- export function startGeminiVoiceSession({ config, callbacks }) {
39
- /** @type {VoiceProviderHooks<VoiceInputGeminiConfig>} */
40
- const hooks = {
41
- label: GEMINI_LABEL,
42
- sampleRate: GEMINI_SAMPLE_RATE,
43
- buildWsUrl(config) {
44
- const base = config.baseURL ?? GEMINI_DEFAULT_WS;
45
- return `${base}?key=${encodeURIComponent(config.apiKey)}`;
46
- },
47
- buildSetupMessage(config) {
48
- const model = config.model ?? GEMINI_DEFAULT_MODEL;
49
- /** @type {Record<string, unknown>} */
50
- const generationConfig = {
51
- // https://ai.google.dev/gemini-api/docs/live-api/capabilities#response-modalities
52
- // > The native audio models only support `AUDIO` response modality.
53
- responseModalities: ["AUDIO"],
54
- maxOutputTokens: 1,
55
- };
56
- if (model.includes("2.5")) {
57
- generationConfig.thinkingConfig = { thinkingBudget: 0 };
58
- }
59
- /** @type {Record<string, unknown>} */
60
- const setup = {
61
- model: `models/${model}`,
62
- generationConfig,
63
- inputAudioTranscription: {},
64
- };
65
- if (config.language) {
66
- setup.systemInstruction = {
67
- parts: [{ text: `The user is speaking in ${config.language}.` }],
68
- };
69
- }
70
- return { setup };
71
- },
72
- isReadyMessage(message) {
73
- return isObjectLike(message) && "setupComplete" in message;
74
- },
75
- extractTranscript(message) {
76
- if (!isObjectLike(message)) return undefined;
77
- const serverContent = message.serverContent;
78
- if (!isObjectLike(serverContent)) return undefined;
79
- const transcription = serverContent.inputTranscription;
80
- if (
81
- isObjectLike(transcription) &&
82
- typeof transcription.text === "string" &&
83
- transcription.text.length > 0
84
- ) {
85
- return transcription.text;
86
- }
87
- return undefined;
88
- },
89
- buildAudioPayload(chunk, sampleRate) {
90
- return {
91
- realtimeInput: {
92
- audio: {
93
- data: chunk.toString("base64"),
94
- mimeType: `audio/pcm;rate=${sampleRate}`,
95
- },
96
- },
97
- };
98
- },
99
- };
100
-
101
- return startWebSocketVoiceSession({ hooks, config, callbacks });
102
- }
@@ -1,29 +0,0 @@
1
- import { startGeminiVoiceSession } from "./gemini.mjs";
2
- import { startOpenAIVoiceSession } from "./openai.mjs";
3
- import { failVoiceSessionAsync } from "./session.mjs";
4
-
5
- /**
6
- * @typedef {import("./openai.mjs").VoiceInputOpenAIConfig | import("./gemini.mjs").VoiceInputGeminiConfig} VoiceInputConfig
7
- */
8
- /**
9
- * Start a voice input session. Dispatches to the provider-specific
10
- * implementation based on `config.provider`.
11
- *
12
- * @param {object} options
13
- * @param {VoiceInputConfig} options.config
14
- * @param {import("./session.mjs").VoiceSessionCallbacks} options.callbacks
15
- * @returns {import("./session.mjs").VoiceSession}
16
- */
17
- export function startVoiceSession({ config, callbacks }) {
18
- if (config.provider === "openai") {
19
- return startOpenAIVoiceSession({ config, callbacks });
20
- }
21
- if (config.provider === "gemini") {
22
- return startGeminiVoiceSession({ config, callbacks });
23
- }
24
- const provider = /** @type {{ provider: string }} */ (config).provider;
25
- return failVoiceSessionAsync(
26
- callbacks,
27
- new Error(`Unsupported voiceInput.provider: ${provider}`),
28
- );
29
- }