@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.
@@ -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 === "object" && usage !== null) {
49
- usageHistory.push(usage);
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 (!hasPricing || !config.costs[key]) {
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 === "number") {
148
- const cost = (tokens * costValue) / unitSize;
149
- breakdown[key].cost = cost;
150
- totalCost += cost;
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 || "USD",
156
- unit: config?.unit || "1M",
192
+ return Object.freeze({
193
+ currency: config?.currency ?? "USD",
194
+ unit: config?.unit ?? "1M",
157
195
  breakdown,
158
- totalCost: hasPricing ? totalCost : undefined,
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
- return 1;
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 || AGENT_NOTIFY_CMD_DEFAULT,
269
+ notifyCmd: appConfig.notifyCmd,
274
270
  claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
275
271
  voiceInput: appConfig.voiceInput,
276
272
  });
package/src/model.d.ts CHANGED
@@ -10,7 +10,7 @@ export type ModelInput = {
10
10
 
11
11
  export type ModelOutput = {
12
12
  message: Message;
13
- providerTokenUsage: ProviderTokenUsage;
13
+ providerTokenUsage?: ProviderTokenUsage;
14
14
  };
15
15
 
16
16
  export type ProviderTokenUsage = Record<
@@ -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
- <<<<<<< SEARCH ${nonce}
30
+ <<< ${nonce} <<< SEARCH
35
31
  old content
36
- ======= ${nonce}
32
+ === ${nonce} ===
37
33
  new content
38
- >>>>>>> REPLACE ${nonce}
34
+ >>> ${nonce} >>> REPLACE
39
35
 
40
- <<<<<<< SEARCH ${nonce}
36
+ <<< ${nonce} <<< SEARCH
41
37
  other old content
42
- ======= ${nonce}
38
+ === ${nonce} ===
43
39
  other new content
44
- >>>>>>> REPLACE ${nonce}
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
- `<<<<<<< SEARCH ${nonce}\\n(.*?)\\n======= ${nonce}\\n(.*?)\\n?>>>>>>> REPLACE ${nonce}`,
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. All markers must include the nonce, e.g., <<<<<<< SEARCH ${nonce} / ======= ${nonce} / >>>>>>> REPLACE ${nonce}`,
71
+ `Invalid diff format. Each markers must include the nonce: <<< ${nonce} <<< SEARCH, === ${nonce} ===, >>> ${nonce} >>> REPLACE`,
73
72
  );
74
73
  }
75
74
  let newContent = content;
@@ -2,16 +2,17 @@ import { execFileSync } from "node:child_process";
2
2
  import { noThrowSync } from "./noThrow.mjs";
3
3
 
4
4
  /**
5
- * @param {string=} notifyCmd
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(/** @type {string} */ (notifyCmd), [], {
15
+ execFileSync(notifyCmd.command, notifyCmd.args ?? [], {
15
16
  shell: false,
16
17
  stdio: ["ignore", "inherit", "pipe"],
17
18
  env: {
@@ -1,16 +1,10 @@
1
1
  import {
2
- createCJKSpaceNormalizer,
3
- detectRecorder,
4
- failVoiceSessionAsync,
5
- getRecorderCandidates,
6
- isCommandAvailable,
7
2
  isObjectLike,
8
- startRecorder,
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
- const recorder =
49
- config.recorder ??
50
- detectRecorder(getRecorderCandidates(GEMINI_SAMPLE_RATE));
51
- if (!recorder) {
52
- return failVoiceSessionAsync(
53
- callbacks,
54
- new Error(
55
- "No voice recorder found. Install arecord, sox, or ffmpeg (or set `voiceInput.recorder`).",
56
- ),
57
- );
58
- }
59
-
60
- if (!isCommandAvailable(recorder.command)) {
61
- return failVoiceSessionAsync(
62
- callbacks,
63
- new Error(
64
- `Voice recorder command "${recorder.command}" not found on PATH.`,
65
- ),
66
- );
67
- }
68
-
69
- const model = config.model ?? GEMINI_DEFAULT_MODEL;
70
- const base = config.baseURL ?? GEMINI_DEFAULT_WS;
71
-
72
- let stopped = false;
73
- let closeEmitted = false;
74
- let ready = false;
75
- /** @type {Buffer[]} */
76
- const pendingAudio = [];
77
- const normalizer = createCJKSpaceNormalizer();
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
- onError(err) {
99
- if (!stopped) callbacks.onError(err);
100
- stop();
75
+ isReadyMessage(message) {
76
+ return isObjectLike(message) && "setupComplete" in message;
101
77
  },
102
- onExit() {
103
- stop();
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
- * @param {Buffer} chunk
109
- */
110
- function sendAudio(chunk) {
111
- const payload = {
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
- try {
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 { stop };
104
+ return startWebSocketVoiceSession({ hooks, config, callbacks });
257
105
  }