@iinm/plain-agent 1.7.19 → 1.7.21

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/src/cliCost.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { styleText } from "node:util";
6
- import { readUsageRecords } from "./usageStore.mjs";
6
+ import * as usageStore from "./usageStore.mjs";
7
7
 
8
8
  /**
9
9
  * @typedef {Object} CostPeriod
@@ -72,15 +72,15 @@ export function parseDateOnly(value) {
72
72
  if (!match) {
73
73
  throw new Error(`Invalid date: "${value}" (expected YYYY-MM-DD)`);
74
74
  }
75
- const [, y, m, d] = match;
76
- const year = Number(y);
77
- const month = Number(m);
78
- const day = Number(d);
79
- const date = new Date(year, month - 1, day);
75
+ const [, year, month, day] = match;
76
+ const y = Number(year);
77
+ const m = Number(month);
78
+ const d = Number(day);
79
+ const date = new Date(y, m - 1, d);
80
80
  if (
81
- date.getFullYear() !== year ||
82
- date.getMonth() !== month - 1 ||
83
- date.getDate() !== day
81
+ date.getFullYear() !== y ||
82
+ date.getMonth() !== m - 1 ||
83
+ date.getDate() !== d
84
84
  ) {
85
85
  throw new Error(`Invalid date: "${value}"`);
86
86
  }
@@ -109,6 +109,10 @@ export function aggregateUsage(records, period) {
109
109
  let excludedOutOfRange = 0;
110
110
 
111
111
  for (const record of records) {
112
+ if (record.timestamp == null) {
113
+ excludedOutOfRange++;
114
+ continue;
115
+ }
112
116
  const recordedAt = new Date(record.timestamp);
113
117
  if (Number.isNaN(recordedAt.getTime())) {
114
118
  excludedOutOfRange++;
@@ -123,12 +127,13 @@ export function aggregateUsage(records, period) {
123
127
  noPricingSessionCount++;
124
128
  continue;
125
129
  }
126
-
127
- let perDate = byCurrency.get(record.currency);
128
- if (!perDate) {
129
- perDate = new Map();
130
- byCurrency.set(record.currency, perDate);
130
+ if (!record.currency || typeof record.currency !== "string") {
131
+ excludedOutOfRange++;
132
+ continue;
131
133
  }
134
+
135
+ const perDate = byCurrency.get(record.currency) ?? new Map();
136
+ byCurrency.set(record.currency, perDate);
132
137
  const existing = perDate.get(localDate);
133
138
  if (existing) {
134
139
  existing.totalCost += record.totalCost;
@@ -146,13 +151,14 @@ export function aggregateUsage(records, period) {
146
151
  const aggregations = [];
147
152
  for (const [currency, perDate] of byCurrency) {
148
153
  const daily = Array.from(perDate.values()).sort((a, b) =>
149
- a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
150
- );
151
- const totalCost = daily.reduce((sum, entry) => sum + entry.totalCost, 0);
152
- const sessionCount = daily.reduce(
153
- (sum, entry) => sum + entry.sessionCount,
154
- 0,
154
+ a.date.localeCompare(b.date),
155
155
  );
156
+ let totalCost = 0;
157
+ let sessionCount = 0;
158
+ for (const entry of daily) {
159
+ totalCost += entry.totalCost;
160
+ sessionCount += entry.sessionCount;
161
+ }
156
162
  aggregations.push({ currency, daily, totalCost, sessionCount });
157
163
  }
158
164
  aggregations.sort((a, b) => a.currency.localeCompare(b.currency));
@@ -166,6 +172,14 @@ export function aggregateUsage(records, period) {
166
172
  };
167
173
  }
168
174
 
175
+ /**
176
+ * @param {number} count
177
+ * @returns {string}
178
+ */
179
+ function formatSessions(count) {
180
+ return `${count} session${count === 1 ? "" : "s"}`;
181
+ }
182
+
169
183
  /**
170
184
  * Render a cost report as a human-readable string.
171
185
  *
@@ -175,10 +189,9 @@ export function aggregateUsage(records, period) {
175
189
  */
176
190
  export function formatCostReport(report, options = {}) {
177
191
  const color = options.color ?? true;
178
- const style = color
179
- ? styleText
180
- : /** @param {any} _modifiers @param {string} text */ (_modifiers, text) =>
181
- text;
192
+ /** @param {string | string[]} _modifiers @param {string} text @returns {string} */
193
+ const plainStyle = (_modifiers, text) => text;
194
+ const style = color ? styleText : plainStyle;
182
195
 
183
196
  const lines = [];
184
197
  lines.push(
@@ -204,14 +217,14 @@ export function formatCostReport(report, options = {}) {
204
217
  lines.push(style("bold", `Daily cost (${agg.currency}):`));
205
218
  for (const entry of agg.daily) {
206
219
  lines.push(
207
- ` ${entry.date} ${formatCost(entry.totalCost)} ${agg.currency} (${entry.sessionCount} session${entry.sessionCount === 1 ? "" : "s"})`,
220
+ ` ${entry.date} ${formatCost(entry.totalCost)} ${agg.currency} (${formatSessions(entry.sessionCount)})`,
208
221
  );
209
222
  }
210
223
  lines.push("");
211
224
  lines.push(
212
225
  style(
213
226
  "bold",
214
- `Total: ${formatCost(agg.totalCost)} ${agg.currency} (${agg.sessionCount} session${agg.sessionCount === 1 ? "" : "s"})`,
227
+ `Total: ${formatCost(agg.totalCost)} ${agg.currency} (${formatSessions(agg.sessionCount)})`,
215
228
  ),
216
229
  );
217
230
  }
@@ -242,28 +255,50 @@ function formatCost(value) {
242
255
  * Run the `plain cost` subcommand.
243
256
  *
244
257
  * @param {{ from: string | null, to: string | null }} args
258
+ * @param {{ readUsageRecords?: typeof import("./usageStore.mjs").readUsageRecords }} [deps]
245
259
  * @returns {Promise<number>} exit code
246
260
  */
247
- export async function runCostCommand(args) {
248
- const { from, to } = resolvePeriod(args);
261
+ export async function runCostCommand(args, deps = {}) {
262
+ let from;
263
+ let to;
264
+ try {
265
+ ({ from, to } = resolvePeriod(args));
266
+ } catch (err) {
267
+ if (err instanceof Error) {
268
+ console.error(`Error: ${err.message}`);
269
+ } else {
270
+ console.error("Error: invalid period arguments");
271
+ }
272
+ return 1;
273
+ }
249
274
 
250
- const { records, skipped } = await readUsageRecords();
275
+ const { records, skipped } = await (
276
+ deps.readUsageRecords ?? usageStore.readUsageRecords
277
+ )();
251
278
  if (skipped.length > 0) {
279
+ const details = skipped
280
+ .slice(0, 3)
281
+ .map((s) => `line ${s.line}: ${s.reason}`)
282
+ .join(", ");
283
+ const ellipsis =
284
+ skipped.length > 3 ? `, and ${skipped.length - 3} more` : "";
252
285
  console.error(
253
- `Warning: skipped ${skipped.length} malformed line(s) in usage log.`,
286
+ `Warning: skipped ${skipped.length} malformed line(s) in usage log (${details}${ellipsis}).`,
254
287
  );
255
288
  }
256
289
 
257
290
  const report = aggregateUsage(records, { from, to });
258
291
  console.log(formatCostReport(report));
259
- return 0;
292
+ return skipped.length > 0 ? 1 : 0;
260
293
  }
261
294
 
262
295
  /**
296
+ * Resolve a period from CLI arguments, falling back to the current month.
297
+ *
263
298
  * @param {{ from: string | null, to: string | null }} args
264
299
  * @returns {CostPeriod}
265
300
  */
266
- function resolvePeriod(args) {
301
+ export function resolvePeriod(args) {
267
302
  const fallback = defaultPeriod();
268
303
  const from = args.from ?? fallback.from;
269
304
  const to = args.to ?? fallback.to;
@@ -91,7 +91,7 @@ export function formatToolUse(toolUse) {
91
91
  const diffs = [];
92
92
  const matches = Array.from(
93
93
  diff.matchAll(
94
- /<<<<<<< SEARCH [0-9a-z]{3}\n(.*?)\n======= [0-9a-z]{3}\n(.*?)\n?>>>>>>> REPLACE [0-9a-z]{3}/gs,
94
+ /<<< [0-9a-z]{3} <<< SEARCH\n(.*?)\n=== [0-9a-z]{3} ===\n(.*?)\n?>>> [0-9a-z]{3} >>> REPLACE/gs,
95
95
  ),
96
96
  );
97
97
  for (const match of matches) {
@@ -57,7 +57,7 @@ const HELP_MESSAGE = [
57
57
  * @property {AgentCommands} agentCommands
58
58
  * @property {string} sessionId
59
59
  * @property {string} modelName
60
- * @property {string} notifyCmd
60
+ * @property {{ command: string; args?: string[] } | undefined} notifyCmd
61
61
  * @property {boolean} sandbox
62
62
  * @property {() => Promise<void>} onStop
63
63
  * @property {ClaudeCodePlugin[]} [claudeCodePlugins]
package/src/config.d.ts CHANGED
@@ -21,14 +21,14 @@ export type AppConfig = {
21
21
  askURL?: AskURLToolOptions;
22
22
  };
23
23
  mcpServers?: Record<string, MCPServerConfig>;
24
- notifyCmd?: string;
24
+ notifyCmd?: { command: string; args?: string[] };
25
25
  voiceInput?: VoiceInputConfig;
26
26
  claudeCodePlugins?: ClaudeCodePluginRepo[];
27
27
  };
28
28
 
29
29
  export type MCPServerConfig = {
30
30
  command: string;
31
- args: string[];
31
+ args?: string[];
32
32
  env?: Record<string, string>;
33
33
  options?: {
34
34
  enabledTools?: string[];
package/src/config.mjs CHANGED
@@ -93,7 +93,7 @@ export async function loadAppConfig(options = {}) {
93
93
  ...(merged.mcpServers ?? {}),
94
94
  ...(config.mcpServers ?? {}),
95
95
  },
96
- notifyCmd: config.notifyCmd || merged.notifyCmd,
96
+ notifyCmd: config.notifyCmd ?? merged.notifyCmd,
97
97
  claudeCodePlugins: [
98
98
  ...(merged.claudeCodePlugins ?? []),
99
99
  ...(config.claudeCodePlugins ?? []),
@@ -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: {