@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 +1 -48
- package/config/config.predefined.json +18 -117
- package/package.json +1 -1
- package/src/cli/interactive.mjs +2 -118
- package/src/cli/interruptTransform.mjs +7 -22
- package/src/config.d.ts +0 -2
- package/src/config.mjs +0 -3
- package/src/main.mjs +0 -1
- package/src/cli/muteTransform.mjs +0 -26
- package/src/voice/gemini.mjs +0 -102
- package/src/voice/input.mjs +0 -29
- package/src/voice/openai.mjs +0 -102
- package/src/voice/session.mjs +0 -543
- package/src/voice/toggleKey.mjs +0 -62
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://deepwiki.com/iinm/plain-agent)
|
|
4
4
|
[](https://www.npmjs.com/package/@iinm/plain-agent)
|
|
5
5
|
[](https://packagephobia.com/result?p=@iinm/plain-agent)
|
|
6
|
-
[](https://socket.dev/npm/package/@iinm/plain-agent)
|
|
7
7
|
[](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":
|
|
8
|
+
"input": { "command": "find" },
|
|
9
9
|
"action": "deny",
|
|
10
|
-
"reason": "Use
|
|
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
package/src/cli/interactive.mjs
CHANGED
|
@@ -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) ->
|
|
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(
|
|
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)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
@@ -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
|
-
}
|
package/src/voice/gemini.mjs
DELETED
|
@@ -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
|
-
}
|
package/src/voice/input.mjs
DELETED
|
@@ -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
|
-
}
|