@iinm/plain-agent 1.7.16 → 1.7.18

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.
@@ -0,0 +1,671 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+
3
+ /**
4
+ * @typedef {VoiceInputOpenAIConfig | VoiceInputGeminiConfig} VoiceInputConfig
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} VoiceInputOpenAIConfig
9
+ * @property {"openai"} provider
10
+ * @property {string} apiKey
11
+ * @property {string} [model] - Defaults to "gpt-4o-transcribe".
12
+ * @property {string} [language] - ISO-639-1 code (e.g. "ja", "en"). Improves accuracy and latency when set.
13
+ * @property {string} [baseURL]
14
+ * @property {VoiceRecorderConfig} [recorder]
15
+ * @property {string} [toggleKey] - "ctrl-<char>". Defaults to "ctrl-o".
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} VoiceInputGeminiConfig
20
+ * @property {"gemini"} provider
21
+ * @property {string} apiKey
22
+ * @property {string} [model] - Defaults to "gemini-3.1-flash-live-preview".
23
+ * @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.
24
+ * @property {string} [baseURL]
25
+ * @property {VoiceRecorderConfig} [recorder]
26
+ * @property {string} [toggleKey]
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} VoiceRecorderConfig
31
+ * @property {string} command
32
+ * @property {string[]} args
33
+ * Must write raw 16-bit little-endian mono PCM to stdout at the sample
34
+ * rate required by the chosen provider (24 kHz for OpenAI, 16 kHz for
35
+ * Gemini).
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} VoiceSessionCallbacks
40
+ * @property {(text: string) => void} onTranscript
41
+ * @property {(error: Error) => void} onError
42
+ * @property {() => void} [onClose]
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} VoiceSession
47
+ * @property {() => Promise<void>} stop
48
+ */
49
+
50
+ const DEBUG = process.env.PLAIN_VOICE_DEBUG === "1";
51
+
52
+ // Bytes reserved for other terminal/readline uses — cannot be used as a voice toggle.
53
+ // 0x03 = Ctrl-C (SIGINT)
54
+ // 0x04 = Ctrl-D (EOF / readline exit)
55
+ // 0x09 = Ctrl-I (Tab)
56
+ // 0x0a = Ctrl-J (LF / Enter)
57
+ // 0x0d = Ctrl-M (CR / Enter)
58
+ // 0x11 = Ctrl-Q (XON: resume terminal output)
59
+ // 0x13 = Ctrl-S (XOFF: suspend terminal output)
60
+ const RESERVED_TERMINAL_BYTES = new Set([
61
+ 0x03, 0x04, 0x09, 0x0a, 0x0d, 0x11, 0x13,
62
+ ]);
63
+
64
+ /**
65
+ * @typedef {Object} VoiceToggleKey
66
+ * @property {number} byte
67
+ * @property {string} label
68
+ */
69
+
70
+ /**
71
+ * Parse a "ctrl-<char>" binding into the raw byte the terminal sends in
72
+ * raw mode. Only Ctrl-<char> is supported because it is the only family
73
+ * the pre-readline pipeline can recognize without a full key decoder.
74
+ *
75
+ * @param {string | undefined} spec
76
+ * @returns {VoiceToggleKey}
77
+ */
78
+ export function parseVoiceToggleKey(spec) {
79
+ const raw = (spec ?? "ctrl-o").trim().toLowerCase();
80
+
81
+ const match = /^ctrl-(.)$/.exec(raw);
82
+ if (!match) {
83
+ throw new Error(
84
+ `Invalid voiceInput.toggleKey "${spec}". Expected "ctrl-<char>".`,
85
+ );
86
+ }
87
+
88
+ const ch = match[1];
89
+ const code = ch.charCodeAt(0);
90
+
91
+ // Subtracting a fixed offset from the character's ASCII code yields the
92
+ // control byte (0x01–0x1f) the terminal sends for that Ctrl combination.
93
+ let byte;
94
+ if (code >= 0x61 && code <= 0x7a) {
95
+ // a–z (0x61–0x7a): subtract 0x60 → 0x01 (Ctrl-A) – 0x1a (Ctrl-Z)
96
+ byte = code - 0x60;
97
+ } else if (code >= 0x5b && code <= 0x5f) {
98
+ // [ \ ] ^ _ (0x5b–0x5f): subtract 0x40 → 0x1b (Ctrl-[) – 0x1f (Ctrl-_)
99
+ byte = code - 0x40;
100
+ } else {
101
+ throw new Error(
102
+ `Unsupported voiceInput.toggleKey "${spec}". Use ctrl-<letter> or ctrl-<[ \\ ] ^ _>.`,
103
+ );
104
+ }
105
+
106
+ if (RESERVED_TERMINAL_BYTES.has(byte)) {
107
+ throw new Error(
108
+ `voiceInput.toggleKey "${spec}" conflicts with a reserved terminal/readline key.`,
109
+ );
110
+ }
111
+
112
+ return { byte, label: `Ctrl-${ch.toUpperCase()}` };
113
+ }
114
+
115
+ /**
116
+ * @param {number} sampleRate
117
+ * @returns {VoiceRecorderConfig[]}
118
+ */
119
+ export function getRecorderCandidates(sampleRate) {
120
+ const rate = String(sampleRate);
121
+ const isMac = process.platform === "darwin";
122
+ /** @type {VoiceRecorderConfig[]} */
123
+ const candidates = [];
124
+
125
+ if (!isMac) {
126
+ candidates.push({
127
+ command: "arecord",
128
+ args: ["-q", "-f", "S16_LE", "-c", "1", "-r", rate, "-t", "raw"],
129
+ });
130
+ }
131
+
132
+ candidates.push({
133
+ command: "sox",
134
+ args: [
135
+ "-q",
136
+ "-d",
137
+ "-b",
138
+ "16",
139
+ "-c",
140
+ "1",
141
+ "-r",
142
+ rate,
143
+ "-e",
144
+ "signed-integer",
145
+ "-t",
146
+ "raw",
147
+ "-",
148
+ ],
149
+ });
150
+
151
+ const ffmpegInput = isMac
152
+ ? ["-f", "avfoundation", "-i", ":0"]
153
+ : ["-f", "alsa", "-i", "default"];
154
+ candidates.push({
155
+ command: "ffmpeg",
156
+ args: [
157
+ "-hide_banner",
158
+ "-loglevel",
159
+ "error",
160
+ ...ffmpegInput,
161
+ "-ac",
162
+ "1",
163
+ "-ar",
164
+ rate,
165
+ "-f",
166
+ "s16le",
167
+ "-",
168
+ ],
169
+ });
170
+
171
+ return candidates;
172
+ }
173
+
174
+ /**
175
+ * @param {VoiceRecorderConfig[]} candidates
176
+ * @returns {VoiceRecorderConfig | null}
177
+ */
178
+ export function detectRecorder(candidates) {
179
+ return candidates.find((c) => isCommandAvailable(c.command)) ?? null;
180
+ }
181
+
182
+ /**
183
+ * @param {string} command
184
+ */
185
+ function isCommandAvailable(command) {
186
+ if (process.platform === "win32") {
187
+ const result = spawnSync("where", [command], { stdio: "ignore" });
188
+ return result.status === 0;
189
+ }
190
+ const result = spawnSync("sh", ["-c", `command -v ${command}`], {
191
+ stdio: "ignore",
192
+ });
193
+ return result.status === 0;
194
+ }
195
+
196
+ /**
197
+ * Start a voice input session. Spawns a recorder, opens a WebSocket to the
198
+ * configured provider, and streams transcript deltas via `onTranscript`.
199
+ *
200
+ * @param {object} options
201
+ * @param {VoiceInputConfig} options.config
202
+ * @param {VoiceSessionCallbacks} options.callbacks
203
+ * @returns {VoiceSession}
204
+ */
205
+ export function startVoiceSession({ config, callbacks }) {
206
+ /**
207
+ * Report an error asynchronously and return an already-terminated session.
208
+ * @param {Error} error
209
+ * @returns {VoiceSession}
210
+ */
211
+ function failAsync(error) {
212
+ queueMicrotask(() => {
213
+ callbacks.onError(error);
214
+ callbacks.onClose?.();
215
+ });
216
+ return { stop: async () => {} };
217
+ }
218
+
219
+ /** @type {VoiceDriver} */
220
+ let driver;
221
+ try {
222
+ driver = createDriver(config);
223
+ } catch (err) {
224
+ return failAsync(err instanceof Error ? err : new Error(String(err)));
225
+ }
226
+
227
+ const recorder =
228
+ config.recorder ?? detectRecorder(getRecorderCandidates(driver.sampleRate));
229
+ if (!recorder) {
230
+ return failAsync(
231
+ new Error(
232
+ "No voice recorder found. Install arecord, sox, or ffmpeg (or set `voiceInput.recorder`).",
233
+ ),
234
+ );
235
+ }
236
+
237
+ if (!isCommandAvailable(recorder.command)) {
238
+ return failAsync(
239
+ new Error(
240
+ `Voice recorder command "${recorder.command}" not found on PATH.`,
241
+ ),
242
+ );
243
+ }
244
+
245
+ let stopped = false;
246
+ let closeEmitted = false;
247
+ let ready = false;
248
+ /** @type {Buffer[]} */
249
+ const pendingAudio = [];
250
+ const normalizer = createCJKSpaceNormalizer();
251
+
252
+ const emitClose = () => {
253
+ if (closeEmitted) return;
254
+ closeEmitted = true;
255
+ callbacks.onClose?.();
256
+ };
257
+
258
+ const ws = driver.connect();
259
+ ws.binaryType = "arraybuffer";
260
+
261
+ const child = spawn(recorder.command, recorder.args, {
262
+ stdio: ["ignore", "pipe", "pipe"],
263
+ });
264
+
265
+ /** @type {string[]} */
266
+ const recorderStderr = [];
267
+ child.stderr.on("data", (chunk) => {
268
+ recorderStderr.push(chunk.toString("utf8"));
269
+ });
270
+
271
+ child.on("error", (err) => {
272
+ if (stopped) return;
273
+ const suffix =
274
+ /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
275
+ ? ` (command "${recorder.command}" not found)`
276
+ : "";
277
+ callbacks.onError(
278
+ new Error(`Recorder failed to start${suffix}: ${err.message}`),
279
+ );
280
+ stop();
281
+ });
282
+
283
+ child.on("exit", (code, signal) => {
284
+ if (stopped) return;
285
+ if (code !== 0 && signal === null) {
286
+ const stderrText = recorderStderr.join("").trim();
287
+ callbacks.onError(
288
+ new Error(
289
+ `Recorder "${recorder.command}" exited with code ${code}${
290
+ stderrText ? `: ${stderrText}` : ""
291
+ }`,
292
+ ),
293
+ );
294
+ }
295
+ stop();
296
+ });
297
+
298
+ child.stdout.on("data", (chunk) => {
299
+ if (stopped) return;
300
+ if (ready && ws.readyState === WebSocket.OPEN) {
301
+ sendAudio(chunk);
302
+ } else {
303
+ pendingAudio.push(chunk);
304
+ }
305
+ });
306
+
307
+ ws.addEventListener("open", () => {
308
+ try {
309
+ ws.send(JSON.stringify(driver.buildSetup()));
310
+ } catch (err) {
311
+ callbacks.onError(
312
+ new Error(
313
+ `Failed to send setup message: ${err instanceof Error ? err.message : String(err)}`,
314
+ ),
315
+ );
316
+ stop();
317
+ }
318
+ });
319
+
320
+ ws.addEventListener("message", (event) => {
321
+ if (stopped) return;
322
+ let message;
323
+ let raw = "";
324
+ try {
325
+ raw =
326
+ typeof event.data === "string"
327
+ ? event.data
328
+ : Buffer.from(/** @type {ArrayBuffer} */ (event.data)).toString(
329
+ "utf8",
330
+ );
331
+ message = JSON.parse(raw);
332
+ } catch (err) {
333
+ callbacks.onError(
334
+ new Error(
335
+ `Failed to parse server message: ${err instanceof Error ? err.message : String(err)}`,
336
+ ),
337
+ );
338
+ return;
339
+ }
340
+ if (!isObject(message)) return;
341
+ if (DEBUG) {
342
+ process.stderr.write(`[voiceInput] <- ${raw.slice(0, 800)}\n`);
343
+ }
344
+
345
+ if (message.type === "error" && isObject(message.error)) {
346
+ const detail =
347
+ typeof message.error.message === "string"
348
+ ? message.error.message
349
+ : JSON.stringify(message.error);
350
+ callbacks.onError(new Error(`${driver.label} error: ${detail}`));
351
+ return;
352
+ }
353
+
354
+ if (!ready && driver.isReady(message)) {
355
+ ready = true;
356
+ for (const chunk of pendingAudio.splice(0)) {
357
+ if (ws.readyState === WebSocket.OPEN) sendAudio(chunk);
358
+ }
359
+ return;
360
+ }
361
+
362
+ const text = driver.parseTranscript(message);
363
+ if (text !== null) {
364
+ const normalized = normalizer.push(text);
365
+ if (normalized.length > 0) {
366
+ callbacks.onTranscript(normalized);
367
+ }
368
+ }
369
+ });
370
+
371
+ ws.addEventListener("error", (event) => {
372
+ if (stopped) return;
373
+ const message =
374
+ /** @type {{ message?: string }} */ (event).message ?? "WebSocket error";
375
+ callbacks.onError(new Error(`${driver.label} WebSocket error: ${message}`));
376
+ stop();
377
+ });
378
+
379
+ ws.addEventListener("close", (event) => {
380
+ if (!stopped && event.code !== 1000 && event.code !== 1005) {
381
+ const reason = event.reason ? `: ${event.reason}` : "";
382
+ callbacks.onError(
383
+ new Error(
384
+ `${driver.label} WebSocket closed (code ${event.code}${reason})`,
385
+ ),
386
+ );
387
+ }
388
+ stopped = true;
389
+ try {
390
+ child.kill("SIGTERM");
391
+ } catch {
392
+ // ignore
393
+ }
394
+ emitClose();
395
+ });
396
+
397
+ /**
398
+ * @param {Buffer} chunk
399
+ */
400
+ function sendAudio(chunk) {
401
+ const payload = driver.buildAudioMessage(chunk.toString("base64"));
402
+ try {
403
+ ws.send(JSON.stringify(payload));
404
+ } catch {
405
+ // connection may have just closed
406
+ }
407
+ }
408
+
409
+ if (DEBUG) {
410
+ process.stderr.write(
411
+ `[voiceInput] driver=${driver.label} recorder=${recorder.command} ${recorder.args.join(" ")}\n`,
412
+ );
413
+ }
414
+
415
+ /**
416
+ * @returns {Promise<void>}
417
+ */
418
+ async function stop() {
419
+ if (stopped) return;
420
+ stopped = true;
421
+ try {
422
+ child.kill("SIGTERM");
423
+ } catch {
424
+ // ignore
425
+ }
426
+ if (
427
+ ws.readyState === WebSocket.OPEN ||
428
+ ws.readyState === WebSocket.CONNECTING
429
+ ) {
430
+ try {
431
+ ws.close(1000, "client stop");
432
+ } catch {
433
+ // ignore
434
+ }
435
+ }
436
+ emitClose();
437
+ }
438
+
439
+ return { stop };
440
+ }
441
+
442
+ /**
443
+ * @typedef {Object} VoiceDriver
444
+ * @property {string} label
445
+ * @property {number} sampleRate
446
+ * @property {() => WebSocket} connect
447
+ * @property {() => object} buildSetup
448
+ * @property {(message: Record<string, unknown>) => boolean} isReady
449
+ * @property {(base64: string) => object} buildAudioMessage
450
+ * @property {(message: Record<string, unknown>) => string | null} parseTranscript
451
+ */
452
+
453
+ /**
454
+ * @param {VoiceInputConfig} config
455
+ * @returns {VoiceDriver}
456
+ */
457
+ function createDriver(config) {
458
+ if (config.provider === "openai") {
459
+ return createOpenAIDriver(config);
460
+ }
461
+ if (config.provider === "gemini") {
462
+ return createGeminiDriver(config);
463
+ }
464
+ throw new Error(
465
+ `Unsupported voiceInput.provider: ${/** @type {{provider: string}} */ (config).provider}`,
466
+ );
467
+ }
468
+
469
+ const OPENAI_DEFAULT_MODEL = "gpt-4o-transcribe";
470
+ const OPENAI_DEFAULT_WS = "wss://api.openai.com/v1/realtime";
471
+ const OPENAI_SAMPLE_RATE = 24000;
472
+
473
+ /**
474
+ * @param {VoiceInputOpenAIConfig} config
475
+ * @returns {VoiceDriver}
476
+ */
477
+ function createOpenAIDriver(config) {
478
+ const model = config.model ?? OPENAI_DEFAULT_MODEL;
479
+ const base = config.baseURL ?? OPENAI_DEFAULT_WS;
480
+ return {
481
+ label: "OpenAI Realtime",
482
+ sampleRate: OPENAI_SAMPLE_RATE,
483
+ connect() {
484
+ // Node's global WebSocket (undici) accepts a non-standard `headers`
485
+ // option. The built-in typings only declare the standards-compliant
486
+ // constructor, so cast through `WebSocket`-as-constructor.
487
+ const Ctor =
488
+ /** @type {new (url: string, opts?: unknown) => WebSocket} */ (
489
+ /** @type {unknown} */ (WebSocket)
490
+ );
491
+ return new Ctor(`${base}?intent=transcription`, {
492
+ headers: {
493
+ Authorization: `Bearer ${config.apiKey}`,
494
+ "OpenAI-Beta": "realtime=v1",
495
+ },
496
+ });
497
+ },
498
+ buildSetup() {
499
+ /** @type {{ model: string, language?: string }} */
500
+ const transcription = { model };
501
+ if (config.language) transcription.language = config.language;
502
+ // The `?intent=transcription` endpoint uses the flat transcription-session
503
+ // schema, not the nested `session.audio.input.*` realtime schema.
504
+ return {
505
+ type: "transcription_session.update",
506
+ session: {
507
+ input_audio_format: "pcm16",
508
+ input_audio_transcription: transcription,
509
+ turn_detection: { type: "server_vad" },
510
+ },
511
+ };
512
+ },
513
+ isReady(message) {
514
+ return (
515
+ message.type === "transcription_session.created" ||
516
+ message.type === "transcription_session.updated"
517
+ );
518
+ },
519
+ buildAudioMessage(base64) {
520
+ return { type: "input_audio_buffer.append", audio: base64 };
521
+ },
522
+ parseTranscript(message) {
523
+ if (
524
+ message.type === "conversation.item.input_audio_transcription.delta" &&
525
+ typeof message.delta === "string" &&
526
+ message.delta.length > 0
527
+ ) {
528
+ return message.delta;
529
+ }
530
+ return null;
531
+ },
532
+ };
533
+ }
534
+
535
+ const GEMINI_DEFAULT_MODEL = "gemini-3.1-flash-live-preview";
536
+ const GEMINI_DEFAULT_WS =
537
+ "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent";
538
+ const GEMINI_SAMPLE_RATE = 16000;
539
+
540
+ /**
541
+ * @param {VoiceInputGeminiConfig} config
542
+ * @returns {VoiceDriver}
543
+ */
544
+ function createGeminiDriver(config) {
545
+ const model = config.model ?? GEMINI_DEFAULT_MODEL;
546
+ const base = config.baseURL ?? GEMINI_DEFAULT_WS;
547
+ return {
548
+ label: "Gemini Live",
549
+ sampleRate: GEMINI_SAMPLE_RATE,
550
+ connect() {
551
+ return new WebSocket(`${base}?key=${encodeURIComponent(config.apiKey)}`);
552
+ },
553
+ buildSetup() {
554
+ // Gemini Live was designed for voice agents, not pure STT.
555
+ // Force maxOutputTokens: 1 and disable thinking on 2.5 models
556
+ // to minimise wasted audio output.
557
+
558
+ /** @type {Record<string, unknown>} */
559
+ const generationConfig = {
560
+ // https://ai.google.dev/gemini-api/docs/live-api/capabilities#response-modalities
561
+ // > The native audio models only support `AUDIO response modality.
562
+ responseModalities: ["AUDIO"],
563
+ maxOutputTokens: 1,
564
+ };
565
+ if (model.includes("2.5")) {
566
+ generationConfig.thinkingConfig = { thinkingBudget: 0 };
567
+ }
568
+ /** @type {Record<string, unknown>} */
569
+ const setup = {
570
+ model: `models/${model}`,
571
+ generationConfig,
572
+ inputAudioTranscription: {},
573
+ };
574
+ if (config.language) {
575
+ setup.systemInstruction = {
576
+ parts: [{ text: `The user is speaking in ${config.language}.` }],
577
+ };
578
+ }
579
+ return { setup };
580
+ },
581
+ isReady(message) {
582
+ return "setupComplete" in message;
583
+ },
584
+ buildAudioMessage(base64) {
585
+ return {
586
+ realtimeInput: {
587
+ audio: {
588
+ data: base64,
589
+ mimeType: `audio/pcm;rate=${GEMINI_SAMPLE_RATE}`,
590
+ },
591
+ },
592
+ };
593
+ },
594
+ parseTranscript(message) {
595
+ const serverContent = message.serverContent;
596
+ if (!isObject(serverContent)) return null;
597
+ const t = serverContent.inputTranscription;
598
+ if (isObject(t) && typeof t.text === "string" && t.text.length > 0) {
599
+ return t.text;
600
+ }
601
+ return null;
602
+ },
603
+ };
604
+ }
605
+
606
+ /**
607
+ * Drop whitespace sitting between two CJK characters. Some providers return
608
+ * Japanese transcripts with morpheme-separating spaces ("そう 、 声 で");
609
+ * mixed strings like "Windows を使う" keep their inter-script spaces.
610
+ *
611
+ * @returns {{ push: (text: string) => string, flush: () => string }}
612
+ */
613
+ export function createCJKSpaceNormalizer() {
614
+ let prevChar = "";
615
+ let pendingSpaces = "";
616
+ const isSpace = (/** @type {string} */ c) =>
617
+ c === " " || c === "\t" || c === "\u3000";
618
+
619
+ return {
620
+ push(text) {
621
+ let out = "";
622
+ for (const ch of text) {
623
+ if (isSpace(ch)) {
624
+ pendingSpaces += ch;
625
+ continue;
626
+ }
627
+ if (pendingSpaces.length > 0) {
628
+ if (!(isCJKChar(prevChar) && isCJKChar(ch))) {
629
+ out += pendingSpaces;
630
+ }
631
+ pendingSpaces = "";
632
+ }
633
+ out += ch;
634
+ prevChar = ch;
635
+ }
636
+ return out;
637
+ },
638
+ flush() {
639
+ const out = pendingSpaces;
640
+ pendingSpaces = "";
641
+ prevChar = "";
642
+ return out;
643
+ },
644
+ };
645
+ }
646
+
647
+ /**
648
+ * @param {string} ch
649
+ * @returns {boolean}
650
+ */
651
+ function isCJKChar(ch) {
652
+ const code = ch.codePointAt(0);
653
+ if (code === undefined) return false;
654
+ return (
655
+ (code >= 0x3000 && code <= 0x33ff) ||
656
+ (code >= 0x3400 && code <= 0x4dbf) ||
657
+ (code >= 0x4e00 && code <= 0x9fff) ||
658
+ (code >= 0xac00 && code <= 0xd7af) ||
659
+ (code >= 0xf900 && code <= 0xfaff) ||
660
+ (code >= 0xff00 && code <= 0xffef) ||
661
+ (code >= 0x20000 && code <= 0x2ffff)
662
+ );
663
+ }
664
+
665
+ /**
666
+ * @param {unknown} value
667
+ * @returns {value is Record<string, unknown>}
668
+ */
669
+ function isObject(value) {
670
+ return typeof value === "object" && value !== null;
671
+ }