@iinm/plain-agent 1.7.19 → 1.7.20
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 +53 -90
- package/config/config.predefined.json +15 -15
- package/package.json +1 -3
- package/src/agentLoop.mjs +3 -1
- package/src/cliCost.mjs +67 -32
- package/src/cliFormatter.mjs +1 -1
- package/src/cliInteractive.mjs +1 -1
- package/src/config.d.ts +2 -2
- package/src/config.mjs +1 -1
- package/src/costTracker.mjs +58 -19
- package/src/env.mjs +0 -6
- package/src/main.mjs +2 -6
- package/src/model.d.ts +1 -1
- package/src/tools/patchFile.mjs +11 -12
- package/src/utils/notify.mjs +3 -2
- package/src/voiceInputGemini.mjs +58 -210
- package/src/voiceInputOpenAI.mjs +63 -220
- package/src/voiceInputSession.mjs +295 -2
- package/bin/plain-notify-terminal-bell +0 -3
package/src/costTracker.mjs
CHANGED
|
@@ -31,23 +31,58 @@
|
|
|
31
31
|
* @property {() => boolean} hasUsage - Check if any usage recorded
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Validate a cost configuration object at runtime.
|
|
36
|
+
* @param {unknown} config
|
|
37
|
+
*/
|
|
38
|
+
function validateCostConfig(config) {
|
|
39
|
+
if (config === undefined) return;
|
|
40
|
+
if (typeof config !== "object" || config === null) {
|
|
41
|
+
throw new TypeError("CostConfig must be an object");
|
|
42
|
+
}
|
|
43
|
+
const c = /** @type {Record<string, unknown>} */ (config);
|
|
44
|
+
if (typeof c.currency !== "string") {
|
|
45
|
+
throw new TypeError("CostConfig.currency must be a string");
|
|
46
|
+
}
|
|
47
|
+
if (typeof c.unit !== "string") {
|
|
48
|
+
throw new TypeError("CostConfig.unit must be a string");
|
|
49
|
+
}
|
|
50
|
+
if (typeof c.costs !== "object" || c.costs === null) {
|
|
51
|
+
throw new TypeError("CostConfig.costs must be an object");
|
|
52
|
+
}
|
|
53
|
+
for (const [key, value] of Object.entries(
|
|
54
|
+
/** @type {Record<string, unknown>} */ (c.costs),
|
|
55
|
+
)) {
|
|
56
|
+
if (typeof value !== "number") {
|
|
57
|
+
throw new TypeError(
|
|
58
|
+
`CostConfig.costs["${key}"] must be a number, got ${typeof value}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
34
64
|
/**
|
|
35
65
|
* Create a cost tracker for session token usage
|
|
36
66
|
* @param {CostConfig} [costConfig] - Optional cost configuration
|
|
37
67
|
* @returns {CostTracker}
|
|
38
68
|
*/
|
|
39
69
|
export function createCostTracker(costConfig) {
|
|
70
|
+
validateCostConfig(costConfig);
|
|
71
|
+
|
|
40
72
|
/** @type {ProviderTokenUsage[]} */
|
|
41
73
|
const usageHistory = [];
|
|
42
74
|
|
|
43
75
|
/**
|
|
44
|
-
* Record token usage from a provider
|
|
76
|
+
* Record token usage from a provider.
|
|
77
|
+
* Throws when usage is not a non-null object.
|
|
45
78
|
* @param {ProviderTokenUsage} usage
|
|
79
|
+
* @throws {TypeError} when usage is null, undefined, or not an object
|
|
46
80
|
*/
|
|
47
81
|
function recordUsage(usage) {
|
|
48
|
-
if (typeof usage
|
|
49
|
-
|
|
82
|
+
if (typeof usage !== "object" || usage === null) {
|
|
83
|
+
throw new TypeError("usage must be a non-null object");
|
|
50
84
|
}
|
|
85
|
+
usageHistory.push(usage);
|
|
51
86
|
}
|
|
52
87
|
|
|
53
88
|
/**
|
|
@@ -75,12 +110,12 @@ export function createCostTracker(costConfig) {
|
|
|
75
110
|
return usageHistory.length > 0;
|
|
76
111
|
}
|
|
77
112
|
|
|
78
|
-
return {
|
|
113
|
+
return Object.freeze({
|
|
79
114
|
recordUsage,
|
|
80
115
|
getAggregatedUsage,
|
|
81
116
|
calculateCost,
|
|
82
117
|
hasUsage,
|
|
83
|
-
};
|
|
118
|
+
});
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
/**
|
|
@@ -132,40 +167,44 @@ function calculateCostFromConfig(aggregated, config) {
|
|
|
132
167
|
/** @type {Record<string, TokenBreakdown>} */
|
|
133
168
|
const breakdown = {};
|
|
134
169
|
let totalCost = 0;
|
|
135
|
-
const hasPricing = config?.costs;
|
|
136
170
|
|
|
137
171
|
for (const [key, tokens] of Object.entries(aggregated)) {
|
|
138
|
-
breakdown[key] = { tokens, cost: undefined };
|
|
172
|
+
breakdown[key] = Object.freeze({ tokens, cost: undefined });
|
|
139
173
|
|
|
140
|
-
if (!
|
|
174
|
+
if (!config?.costs?.[key]) {
|
|
141
175
|
continue;
|
|
142
176
|
}
|
|
143
177
|
|
|
144
178
|
const costValue = config.costs[key];
|
|
145
179
|
const unitSize = parseUnit(config.unit);
|
|
146
180
|
|
|
147
|
-
if (typeof costValue
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
181
|
+
if (typeof costValue !== "number") {
|
|
182
|
+
throw new TypeError(
|
|
183
|
+
`config.costs["${key}"] must be a number, got ${typeof costValue}`,
|
|
184
|
+
);
|
|
151
185
|
}
|
|
186
|
+
|
|
187
|
+
const cost = (tokens * costValue) / unitSize;
|
|
188
|
+
breakdown[key] = Object.freeze({ tokens, cost });
|
|
189
|
+
totalCost += cost;
|
|
152
190
|
}
|
|
153
191
|
|
|
154
|
-
return {
|
|
155
|
-
currency: config?.currency
|
|
156
|
-
unit: config?.unit
|
|
192
|
+
return Object.freeze({
|
|
193
|
+
currency: config?.currency ?? "USD",
|
|
194
|
+
unit: config?.unit ?? "1M",
|
|
157
195
|
breakdown,
|
|
158
|
-
totalCost:
|
|
159
|
-
};
|
|
196
|
+
totalCost: config?.costs ? totalCost : undefined,
|
|
197
|
+
});
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
/**
|
|
163
|
-
* Parse unit string to number
|
|
201
|
+
* Parse unit string to number.
|
|
164
202
|
* @param {string} unit
|
|
165
203
|
* @returns {number}
|
|
204
|
+
* @throws {Error} when the unit is not recognized
|
|
166
205
|
*/
|
|
167
206
|
function parseUnit(unit) {
|
|
168
207
|
if (unit === "1M") return 1_000_000;
|
|
169
208
|
if (unit === "1K") return 1_000;
|
|
170
|
-
|
|
209
|
+
throw new Error(`Unknown cost unit: "${unit}"`);
|
|
171
210
|
}
|
package/src/env.mjs
CHANGED
|
@@ -41,10 +41,4 @@ export const MESSAGES_DUMP_FILE_PATH = path.join(
|
|
|
41
41
|
"messages.json",
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
-
export const AGENT_NOTIFY_CMD_DEFAULT = path.join(
|
|
45
|
-
AGENT_ROOT,
|
|
46
|
-
"bin",
|
|
47
|
-
"plain-notify-terminal-bell",
|
|
48
|
-
);
|
|
49
|
-
|
|
50
44
|
export const USER_NAME = process.env.USER || "unknown";
|
package/src/main.mjs
CHANGED
|
@@ -15,11 +15,7 @@ import { startInteractiveSession } from "./cliInteractive.mjs";
|
|
|
15
15
|
import { loadAppConfig } from "./config.mjs";
|
|
16
16
|
import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
|
|
17
17
|
import { loadPrompts } from "./context/loadPrompts.mjs";
|
|
18
|
-
import {
|
|
19
|
-
AGENT_NOTIFY_CMD_DEFAULT,
|
|
20
|
-
AGENT_PROJECT_METADATA_DIR,
|
|
21
|
-
USER_NAME,
|
|
22
|
-
} from "./env.mjs";
|
|
18
|
+
import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
|
|
23
19
|
import { setupMCPServer } from "./mcp.mjs";
|
|
24
20
|
import { createModelCaller } from "./modelCaller.mjs";
|
|
25
21
|
import { createPrompt } from "./prompt.mjs";
|
|
@@ -270,7 +266,7 @@ if (cliArgs.subcommand.type === "cost") {
|
|
|
270
266
|
} else {
|
|
271
267
|
startInteractiveSession({
|
|
272
268
|
...sessionOptions,
|
|
273
|
-
notifyCmd: appConfig.notifyCmd
|
|
269
|
+
notifyCmd: appConfig.notifyCmd,
|
|
274
270
|
claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
|
|
275
271
|
voiceInput: appConfig.voiceInput,
|
|
276
272
|
});
|
package/src/model.d.ts
CHANGED
package/src/tools/patchFile.mjs
CHANGED
|
@@ -26,22 +26,21 @@ export function createPatchFileTool(
|
|
|
26
26
|
},
|
|
27
27
|
diff: {
|
|
28
28
|
description: `
|
|
29
|
-
- Content is searched as an exact match including indentation and line breaks.
|
|
30
|
-
- The first match found will be replaced if there are multiple matches.
|
|
31
|
-
- Use multiple SEARCH/REPLACE blocks with session-scoped nonce (${nonce}) to replace multiple contents.
|
|
32
|
-
|
|
33
29
|
Format:
|
|
34
|
-
|
|
30
|
+
<<< ${nonce} <<< SEARCH
|
|
35
31
|
old content
|
|
36
|
-
|
|
32
|
+
=== ${nonce} ===
|
|
37
33
|
new content
|
|
38
|
-
|
|
34
|
+
>>> ${nonce} >>> REPLACE
|
|
39
35
|
|
|
40
|
-
|
|
36
|
+
<<< ${nonce} <<< SEARCH
|
|
41
37
|
other old content
|
|
42
|
-
|
|
38
|
+
=== ${nonce} ===
|
|
43
39
|
other new content
|
|
44
|
-
|
|
40
|
+
>>> ${nonce} >>> REPLACE
|
|
41
|
+
|
|
42
|
+
- Content is searched as an exact match including indentation and line breaks.
|
|
43
|
+
- The first match found will be replaced if there are multiple matches.
|
|
45
44
|
`.trim(),
|
|
46
45
|
type: "string",
|
|
47
46
|
},
|
|
@@ -62,14 +61,14 @@ other new content
|
|
|
62
61
|
const matches = Array.from(
|
|
63
62
|
diff.matchAll(
|
|
64
63
|
new RegExp(
|
|
65
|
-
|
|
64
|
+
`<<< ${nonce} <<< SEARCH\\n(.*?)\\n=== ${nonce} ===\\n(.*?)\\n?>>> ${nonce} >>> REPLACE`,
|
|
66
65
|
"gs",
|
|
67
66
|
),
|
|
68
67
|
),
|
|
69
68
|
);
|
|
70
69
|
if (matches.length === 0) {
|
|
71
70
|
throw new Error(
|
|
72
|
-
`Invalid diff format.
|
|
71
|
+
`Invalid diff format. Each markers must include the nonce: <<< ${nonce} <<< SEARCH, === ${nonce} ===, >>> ${nonce} >>> REPLACE`,
|
|
73
72
|
);
|
|
74
73
|
}
|
|
75
74
|
let newContent = content;
|
package/src/utils/notify.mjs
CHANGED
|
@@ -2,16 +2,17 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { noThrowSync } from "./noThrow.mjs";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* @param {string
|
|
5
|
+
* @param {{ command: string; args?: string[] } | undefined} notifyCmd
|
|
6
6
|
* @returns {void | Error}
|
|
7
7
|
*/
|
|
8
8
|
export function notify(notifyCmd) {
|
|
9
9
|
if (!notifyCmd) {
|
|
10
|
+
process.stdout.write("\x07");
|
|
10
11
|
return;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
return noThrowSync(() => {
|
|
14
|
-
execFileSync(
|
|
15
|
+
execFileSync(notifyCmd.command, notifyCmd.args ?? [], {
|
|
15
16
|
shell: false,
|
|
16
17
|
stdio: ["ignore", "inherit", "pipe"],
|
|
17
18
|
env: {
|
package/src/voiceInputGemini.mjs
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
-
createCJKSpaceNormalizer,
|
|
3
|
-
detectRecorder,
|
|
4
|
-
failVoiceSessionAsync,
|
|
5
|
-
getRecorderCandidates,
|
|
6
|
-
isCommandAvailable,
|
|
7
2
|
isObjectLike,
|
|
8
|
-
|
|
9
|
-
VOICE_DEBUG,
|
|
3
|
+
startWebSocketVoiceSession,
|
|
10
4
|
} from "./voiceInputSession.mjs";
|
|
11
5
|
|
|
12
6
|
/**
|
|
13
|
-
* @import { VoiceRecorderConfig, VoiceSession, VoiceSessionCallbacks } from "./voiceInputSession.mjs"
|
|
7
|
+
* @import { VoiceProviderHooks, VoiceRecorderConfig, VoiceSession, VoiceSessionCallbacks } from "./voiceInputSession.mjs"
|
|
14
8
|
*/
|
|
15
9
|
|
|
16
10
|
/**
|
|
@@ -45,213 +39,67 @@ const GEMINI_LABEL = "Gemini Live";
|
|
|
45
39
|
* @returns {VoiceSession}
|
|
46
40
|
*/
|
|
47
41
|
export function startGeminiVoiceSession({ config, callbacks }) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const emitClose = () => {
|
|
80
|
-
if (closeEmitted) return;
|
|
81
|
-
closeEmitted = true;
|
|
82
|
-
callbacks.onClose?.();
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const ws = new WebSocket(`${base}?key=${encodeURIComponent(config.apiKey)}`);
|
|
86
|
-
ws.binaryType = "arraybuffer";
|
|
87
|
-
|
|
88
|
-
const rec = startRecorder({
|
|
89
|
-
recorder,
|
|
90
|
-
onAudio(chunk) {
|
|
91
|
-
if (stopped) return;
|
|
92
|
-
if (ready && ws.readyState === WebSocket.OPEN) {
|
|
93
|
-
sendAudio(chunk);
|
|
94
|
-
} else {
|
|
95
|
-
pendingAudio.push(chunk);
|
|
42
|
+
/** @type {VoiceProviderHooks<VoiceInputGeminiConfig>} */
|
|
43
|
+
const hooks = {
|
|
44
|
+
label: GEMINI_LABEL,
|
|
45
|
+
sampleRate: GEMINI_SAMPLE_RATE,
|
|
46
|
+
buildWsUrl(config) {
|
|
47
|
+
const base = config.baseURL ?? GEMINI_DEFAULT_WS;
|
|
48
|
+
return `${base}?key=${encodeURIComponent(config.apiKey)}`;
|
|
49
|
+
},
|
|
50
|
+
buildSetupMessage(config) {
|
|
51
|
+
const model = config.model ?? GEMINI_DEFAULT_MODEL;
|
|
52
|
+
/** @type {Record<string, unknown>} */
|
|
53
|
+
const generationConfig = {
|
|
54
|
+
// https://ai.google.dev/gemini-api/docs/live-api/capabilities#response-modalities
|
|
55
|
+
// > The native audio models only support `AUDIO` response modality.
|
|
56
|
+
responseModalities: ["AUDIO"],
|
|
57
|
+
maxOutputTokens: 1,
|
|
58
|
+
};
|
|
59
|
+
if (model.includes("2.5")) {
|
|
60
|
+
generationConfig.thinkingConfig = { thinkingBudget: 0 };
|
|
61
|
+
}
|
|
62
|
+
/** @type {Record<string, unknown>} */
|
|
63
|
+
const setup = {
|
|
64
|
+
model: `models/${model}`,
|
|
65
|
+
generationConfig,
|
|
66
|
+
inputAudioTranscription: {},
|
|
67
|
+
};
|
|
68
|
+
if (config.language) {
|
|
69
|
+
setup.systemInstruction = {
|
|
70
|
+
parts: [{ text: `The user is speaking in ${config.language}.` }],
|
|
71
|
+
};
|
|
96
72
|
}
|
|
73
|
+
return { setup };
|
|
97
74
|
},
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
stop();
|
|
75
|
+
isReadyMessage(message) {
|
|
76
|
+
return isObjectLike(message) && "setupComplete" in message;
|
|
101
77
|
},
|
|
102
|
-
|
|
103
|
-
|
|
78
|
+
extractTranscript(message) {
|
|
79
|
+
if (!isObjectLike(message)) return undefined;
|
|
80
|
+
const serverContent = message.serverContent;
|
|
81
|
+
if (!isObjectLike(serverContent)) return undefined;
|
|
82
|
+
const transcription = serverContent.inputTranscription;
|
|
83
|
+
if (
|
|
84
|
+
isObjectLike(transcription) &&
|
|
85
|
+
typeof transcription.text === "string" &&
|
|
86
|
+
transcription.text.length > 0
|
|
87
|
+
) {
|
|
88
|
+
return transcription.text;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
104
91
|
},
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
realtimeInput: {
|
|
113
|
-
audio: {
|
|
114
|
-
data: chunk.toString("base64"),
|
|
115
|
-
mimeType: `audio/pcm;rate=${GEMINI_SAMPLE_RATE}`,
|
|
92
|
+
buildAudioPayload(chunk, sampleRate) {
|
|
93
|
+
return {
|
|
94
|
+
realtimeInput: {
|
|
95
|
+
audio: {
|
|
96
|
+
data: chunk.toString("base64"),
|
|
97
|
+
mimeType: `audio/pcm;rate=${sampleRate}`,
|
|
98
|
+
},
|
|
116
99
|
},
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
try {
|
|
120
|
-
ws.send(JSON.stringify(payload));
|
|
121
|
-
} catch {
|
|
122
|
-
// connection may have just closed
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
ws.addEventListener("open", () => {
|
|
127
|
-
/** @type {Record<string, unknown>} */
|
|
128
|
-
const generationConfig = {
|
|
129
|
-
// https://ai.google.dev/gemini-api/docs/live-api/capabilities#response-modalities
|
|
130
|
-
// > The native audio models only support `AUDIO` response modality.
|
|
131
|
-
responseModalities: ["AUDIO"],
|
|
132
|
-
maxOutputTokens: 1,
|
|
133
|
-
};
|
|
134
|
-
if (model.includes("2.5")) {
|
|
135
|
-
generationConfig.thinkingConfig = { thinkingBudget: 0 };
|
|
136
|
-
}
|
|
137
|
-
/** @type {Record<string, unknown>} */
|
|
138
|
-
const setup = {
|
|
139
|
-
model: `models/${model}`,
|
|
140
|
-
generationConfig,
|
|
141
|
-
inputAudioTranscription: {},
|
|
142
|
-
};
|
|
143
|
-
if (config.language) {
|
|
144
|
-
setup.systemInstruction = {
|
|
145
|
-
parts: [{ text: `The user is speaking in ${config.language}.` }],
|
|
146
100
|
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
ws.send(JSON.stringify({ setup }));
|
|
150
|
-
} catch (err) {
|
|
151
|
-
callbacks.onError(
|
|
152
|
-
new Error(
|
|
153
|
-
`Failed to send setup message: ${err instanceof Error ? err.message : String(err)}`,
|
|
154
|
-
),
|
|
155
|
-
);
|
|
156
|
-
stop();
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
ws.addEventListener("message", (event) => {
|
|
161
|
-
if (stopped) return;
|
|
162
|
-
let raw = "";
|
|
163
|
-
let message;
|
|
164
|
-
try {
|
|
165
|
-
raw =
|
|
166
|
-
typeof event.data === "string"
|
|
167
|
-
? event.data
|
|
168
|
-
: Buffer.from(/** @type {ArrayBuffer} */ (event.data)).toString(
|
|
169
|
-
"utf8",
|
|
170
|
-
);
|
|
171
|
-
message = JSON.parse(raw);
|
|
172
|
-
} catch (err) {
|
|
173
|
-
callbacks.onError(
|
|
174
|
-
new Error(
|
|
175
|
-
`Failed to parse server message: ${err instanceof Error ? err.message : String(err)}`,
|
|
176
|
-
),
|
|
177
|
-
);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
if (!isObjectLike(message)) return;
|
|
181
|
-
if (VOICE_DEBUG) {
|
|
182
|
-
process.stderr.write(`[voiceInput] <- ${raw.slice(0, 800)}\n`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (!ready && "setupComplete" in message) {
|
|
186
|
-
ready = true;
|
|
187
|
-
for (const chunk of pendingAudio.splice(0)) {
|
|
188
|
-
if (ws.readyState === WebSocket.OPEN) sendAudio(chunk);
|
|
189
|
-
}
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const serverContent = message.serverContent;
|
|
194
|
-
if (!isObjectLike(serverContent)) return;
|
|
195
|
-
const transcription = serverContent.inputTranscription;
|
|
196
|
-
if (
|
|
197
|
-
isObjectLike(transcription) &&
|
|
198
|
-
typeof transcription.text === "string" &&
|
|
199
|
-
transcription.text.length > 0
|
|
200
|
-
) {
|
|
201
|
-
const normalized = normalizer.push(transcription.text);
|
|
202
|
-
if (normalized.length > 0) {
|
|
203
|
-
callbacks.onTranscript(normalized);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
ws.addEventListener("error", (event) => {
|
|
209
|
-
if (stopped) return;
|
|
210
|
-
const message =
|
|
211
|
-
/** @type {{ message?: string }} */ (event).message ?? "WebSocket error";
|
|
212
|
-
callbacks.onError(new Error(`${GEMINI_LABEL} WebSocket error: ${message}`));
|
|
213
|
-
stop();
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
ws.addEventListener("close", (event) => {
|
|
217
|
-
if (!stopped && event.code !== 1000 && event.code !== 1005) {
|
|
218
|
-
const reason = event.reason ? `: ${event.reason}` : "";
|
|
219
|
-
callbacks.onError(
|
|
220
|
-
new Error(
|
|
221
|
-
`${GEMINI_LABEL} WebSocket closed (code ${event.code}${reason})`,
|
|
222
|
-
),
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
stopped = true;
|
|
226
|
-
rec.stop();
|
|
227
|
-
emitClose();
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
if (VOICE_DEBUG) {
|
|
231
|
-
process.stderr.write(
|
|
232
|
-
`[voiceInput] driver=${GEMINI_LABEL} recorder=${recorder.command} ${recorder.args.join(" ")}\n`,
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* @returns {Promise<void>}
|
|
238
|
-
*/
|
|
239
|
-
async function stop() {
|
|
240
|
-
if (stopped) return;
|
|
241
|
-
stopped = true;
|
|
242
|
-
rec.stop();
|
|
243
|
-
if (
|
|
244
|
-
ws.readyState === WebSocket.OPEN ||
|
|
245
|
-
ws.readyState === WebSocket.CONNECTING
|
|
246
|
-
) {
|
|
247
|
-
try {
|
|
248
|
-
ws.close(1000, "client stop");
|
|
249
|
-
} catch {
|
|
250
|
-
// ignore
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
emitClose();
|
|
254
|
-
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
255
103
|
|
|
256
|
-
return {
|
|
104
|
+
return startWebSocketVoiceSession({ hooks, config, callbacks });
|
|
257
105
|
}
|