@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.
- package/README.md +116 -47
- package/config/config.predefined.json +8 -170
- package/package.json +1 -1
- package/src/cliInteractive.mjs +116 -2
- package/src/cliInterruptTransform.mjs +22 -5
- package/src/cliMuteTransform.mjs +26 -0
- package/src/config.d.ts +2 -0
- package/src/config.mjs +3 -0
- package/src/main.mjs +1 -0
- package/src/voiceInput.mjs +671 -0
package/src/cliInteractive.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "./agent"
|
|
3
3
|
* @import { ClaudeCodePlugin } from "./claudeCodePlugin.mjs"
|
|
4
|
+
* @import { VoiceInputConfig, VoiceSession } from "./voiceInput.mjs"
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import readline from "node:readline";
|
|
@@ -13,8 +14,10 @@ import {
|
|
|
13
14
|
printMessage,
|
|
14
15
|
} from "./cliFormatter.mjs";
|
|
15
16
|
import { createInterruptTransform } from "./cliInterruptTransform.mjs";
|
|
17
|
+
import { createMuteTransform } from "./cliMuteTransform.mjs";
|
|
16
18
|
import { createPasteHandler } from "./cliPasteTransform.mjs";
|
|
17
19
|
import { notify } from "./utils/notify.mjs";
|
|
20
|
+
import { parseVoiceToggleKey, startVoiceSession } from "./voiceInput.mjs";
|
|
18
21
|
|
|
19
22
|
const HELP_MESSAGE = [
|
|
20
23
|
"Commands:",
|
|
@@ -57,6 +60,7 @@ const HELP_MESSAGE = [
|
|
|
57
60
|
* @property {boolean} sandbox
|
|
58
61
|
* @property {() => Promise<void>} onStop
|
|
59
62
|
* @property {ClaudeCodePlugin[]} [claudeCodePlugins]
|
|
63
|
+
* @property {VoiceInputConfig} [voiceInput]
|
|
60
64
|
*/
|
|
61
65
|
|
|
62
66
|
/**
|
|
@@ -72,6 +76,7 @@ export function startInteractiveSession({
|
|
|
72
76
|
sandbox,
|
|
73
77
|
onStop,
|
|
74
78
|
claudeCodePlugins,
|
|
79
|
+
voiceInput,
|
|
75
80
|
}) {
|
|
76
81
|
/** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string }} */
|
|
77
82
|
const state = {
|
|
@@ -80,6 +85,16 @@ export function startInteractiveSession({
|
|
|
80
85
|
subagentName: "",
|
|
81
86
|
};
|
|
82
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Active voice input session, or null when not recording.
|
|
90
|
+
* @type {{ session: VoiceSession, startCursor: number, transcriptLength: number } | null}
|
|
91
|
+
*/
|
|
92
|
+
let voice = null;
|
|
93
|
+
|
|
94
|
+
// Parse the voice toggle key once at startup so misconfiguration fails
|
|
95
|
+
// loudly instead of silently falling back.
|
|
96
|
+
const voiceToggle = parseVoiceToggleKey(voiceInput?.toggleKey);
|
|
97
|
+
|
|
83
98
|
const getCliPrompt = (subagentName = "", flashMessage = "") =>
|
|
84
99
|
[
|
|
85
100
|
"",
|
|
@@ -136,7 +151,100 @@ export function startInteractiveSession({
|
|
|
136
151
|
cli.prompt();
|
|
137
152
|
};
|
|
138
153
|
|
|
154
|
+
const stopVoiceSession = async () => {
|
|
155
|
+
if (!voice) return;
|
|
156
|
+
const current = voice;
|
|
157
|
+
voice = null;
|
|
158
|
+
await current.session.stop();
|
|
159
|
+
cli.setPrompt(currentCliPrompt);
|
|
160
|
+
// @ts-expect-error - internal property
|
|
161
|
+
cli._refreshLine?.();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleVoiceToggle = () => {
|
|
165
|
+
// Ignore while the agent is working.
|
|
166
|
+
if (!state.turn) return;
|
|
167
|
+
|
|
168
|
+
if (voice) {
|
|
169
|
+
stopVoiceSession();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!voiceInput) {
|
|
174
|
+
cli.setPrompt(
|
|
175
|
+
getCliPrompt(
|
|
176
|
+
state.subagentName,
|
|
177
|
+
styleText(
|
|
178
|
+
"yellow",
|
|
179
|
+
`Voice input not configured. Set \`voiceInput\` in your config to enable ${voiceToggle.label}.`,
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
cli.prompt(true);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const startCursor = cli.cursor;
|
|
188
|
+
const session = startVoiceSession({
|
|
189
|
+
config: voiceInput,
|
|
190
|
+
callbacks: {
|
|
191
|
+
onTranscript: (delta) => {
|
|
192
|
+
if (!voice) return;
|
|
193
|
+
const insertAt = voice.startCursor + voice.transcriptLength;
|
|
194
|
+
// Insert delta at the recording's insertion point. User input is
|
|
195
|
+
// swallowed while recording, so the buffer around `insertAt` is
|
|
196
|
+
// stable.
|
|
197
|
+
const before = cli.line.slice(0, insertAt);
|
|
198
|
+
const after = cli.line.slice(insertAt);
|
|
199
|
+
// `line` and `cursor` are declared readonly in the Node typings but
|
|
200
|
+
// are writable at runtime — the existing code already patches
|
|
201
|
+
// `_refreshLine` in the same way.
|
|
202
|
+
const mutableCli = /** @type {{ line: string, cursor: number }} */ (
|
|
203
|
+
/** @type {unknown} */ (cli)
|
|
204
|
+
);
|
|
205
|
+
mutableCli.line = before + delta + after;
|
|
206
|
+
mutableCli.cursor = insertAt + delta.length;
|
|
207
|
+
voice.transcriptLength += delta.length;
|
|
208
|
+
// @ts-expect-error - internal property
|
|
209
|
+
cli._refreshLine?.();
|
|
210
|
+
},
|
|
211
|
+
onError: (err) => {
|
|
212
|
+
voice = null;
|
|
213
|
+
cli.setPrompt(
|
|
214
|
+
getCliPrompt(
|
|
215
|
+
state.subagentName,
|
|
216
|
+
styleText("red", `Voice input error: ${err.message}`),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
cli.prompt(true);
|
|
220
|
+
},
|
|
221
|
+
onClose: () => {
|
|
222
|
+
if (!voice) return;
|
|
223
|
+
voice = null;
|
|
224
|
+
cli.setPrompt(currentCliPrompt);
|
|
225
|
+
// @ts-expect-error - internal property
|
|
226
|
+
cli._refreshLine?.();
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
voice = { session, startCursor, transcriptLength: 0 };
|
|
231
|
+
cli.setPrompt(
|
|
232
|
+
getCliPrompt(
|
|
233
|
+
state.subagentName,
|
|
234
|
+
styleText(["red", "bold"], `● REC (${voiceToggle.label} to stop)`),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
// @ts-expect-error - internal property
|
|
238
|
+
cli._refreshLine?.();
|
|
239
|
+
};
|
|
240
|
+
|
|
139
241
|
const handleCtrlC = () => {
|
|
242
|
+
// Stop voice recording first if active.
|
|
243
|
+
if (voice) {
|
|
244
|
+
stopVoiceSession();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
140
248
|
// Agent turn: pause auto-approve; do not clear input.
|
|
141
249
|
if (!state.turn) {
|
|
142
250
|
agentCommands.pauseAutoApprove();
|
|
@@ -192,14 +300,20 @@ export function startInteractiveSession({
|
|
|
192
300
|
};
|
|
193
301
|
|
|
194
302
|
// Pre-readline pipeline:
|
|
195
|
-
// stdin -> interrupt (Ctrl-C / Ctrl-D) -> paste (bracketed paste) -> readline
|
|
303
|
+
// stdin -> interrupt (Ctrl-C / Ctrl-D) -> mute (voice recording) -> paste (bracketed paste) -> readline
|
|
196
304
|
const interrupt = createInterruptTransform({
|
|
197
305
|
onCtrlC: handleCtrlC,
|
|
198
306
|
onCtrlD: handleCtrlD,
|
|
307
|
+
onVoiceToggle: handleVoiceToggle,
|
|
308
|
+
voiceToggleByte: voiceToggle.byte,
|
|
199
309
|
});
|
|
310
|
+
// While a voice session is recording, swallow all stdin bytes other than
|
|
311
|
+
// Ctrl-C / Ctrl-D / the voice toggle key so transcript insertion stays
|
|
312
|
+
// consistent.
|
|
313
|
+
const mute = createMuteTransform({ isMuted: () => voice !== null });
|
|
200
314
|
const paste = createPasteHandler();
|
|
201
315
|
|
|
202
|
-
process.stdin.pipe(interrupt).pipe(paste.transform);
|
|
316
|
+
process.stdin.pipe(interrupt).pipe(mute).pipe(paste.transform);
|
|
203
317
|
|
|
204
318
|
// Enable bracketed paste mode
|
|
205
319
|
if (process.stdout.isTTY) {
|
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import { Transform } from "node:stream";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Create a Transform that intercepts Ctrl-C (0x03)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
8
|
* readline) never observe it. All other input flows through unchanged.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
+
* Priority when multiple handled bytes appear in the same chunk:
|
|
11
|
+
* Ctrl-C > Ctrl-D > voice toggle.
|
|
10
12
|
*
|
|
11
13
|
* @param {object} handlers
|
|
12
14
|
* @param {() => void} handlers.onCtrlC - Called when Ctrl-C is detected
|
|
13
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).
|
|
14
20
|
* @returns {Transform}
|
|
15
21
|
*/
|
|
16
|
-
export function createInterruptTransform({
|
|
22
|
+
export function createInterruptTransform({
|
|
23
|
+
onCtrlC,
|
|
24
|
+
onCtrlD,
|
|
25
|
+
onVoiceToggle,
|
|
26
|
+
voiceToggleByte = 0x0f,
|
|
27
|
+
}) {
|
|
28
|
+
const voiceToggleChar = String.fromCharCode(voiceToggleByte);
|
|
17
29
|
return new Transform({
|
|
18
30
|
transform(chunk, _encoding, callback) {
|
|
19
31
|
const data = chunk.toString("utf8");
|
|
@@ -27,6 +39,11 @@ export function createInterruptTransform({ onCtrlC, onCtrlD }) {
|
|
|
27
39
|
callback();
|
|
28
40
|
return;
|
|
29
41
|
}
|
|
42
|
+
if (onVoiceToggle && data.includes(voiceToggleChar)) {
|
|
43
|
+
onVoiceToggle();
|
|
44
|
+
callback();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
30
47
|
this.push(chunk);
|
|
31
48
|
callback();
|
|
32
49
|
},
|
|
@@ -0,0 +1,26 @@
|
|
|
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/config.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { AskURLToolOptions } from "./tools/askURL.mjs";
|
|
|
4
4
|
import { AskWebToolOptions } from "./tools/askWeb.mjs";
|
|
5
5
|
import { ExecCommandSanboxConfig } from "./tools/execCommand";
|
|
6
6
|
import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
|
|
7
|
+
import { VoiceInputConfig } from "./voiceInput.mjs";
|
|
7
8
|
|
|
8
9
|
export type AppConfig = {
|
|
9
10
|
model?: string;
|
|
@@ -21,6 +22,7 @@ export type AppConfig = {
|
|
|
21
22
|
};
|
|
22
23
|
mcpServers?: Record<string, MCPServerConfig>;
|
|
23
24
|
notifyCmd?: string;
|
|
25
|
+
voiceInput?: VoiceInputConfig;
|
|
24
26
|
claudeCodePlugins?: ClaudeCodePluginRepo[];
|
|
25
27
|
};
|
|
26
28
|
|
package/src/config.mjs
CHANGED
|
@@ -98,6 +98,9 @@ export async function loadAppConfig(options = {}) {
|
|
|
98
98
|
...(merged.claudeCodePlugins ?? []),
|
|
99
99
|
...(config.claudeCodePlugins ?? []),
|
|
100
100
|
],
|
|
101
|
+
voiceInput: config.voiceInput
|
|
102
|
+
? { ...(merged.voiceInput ?? {}), ...config.voiceInput }
|
|
103
|
+
: merged.voiceInput,
|
|
101
104
|
};
|
|
102
105
|
}
|
|
103
106
|
|
package/src/main.mjs
CHANGED
|
@@ -257,6 +257,7 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
|
|
|
257
257
|
...sessionOptions,
|
|
258
258
|
notifyCmd: appConfig.notifyCmd || AGENT_NOTIFY_CMD_DEFAULT,
|
|
259
259
|
claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
|
|
260
|
+
voiceInput: appConfig.voiceInput,
|
|
260
261
|
});
|
|
261
262
|
}
|
|
262
263
|
})().catch((err) => {
|