@iinm/plain-agent 1.7.18 → 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 +57 -83
- package/config/config.predefined.json +15 -15
- package/package.json +1 -3
- package/src/agentLoop.mjs +3 -1
- package/src/cliArgs.mjs +31 -1
- package/src/cliBatch.mjs +22 -0
- package/src/cliCost.mjs +309 -0
- package/src/cliFormatter.mjs +1 -1
- package/src/cliInteractive.mjs +29 -1
- package/src/config.d.ts +2 -2
- package/src/config.mjs +1 -1
- package/src/costTracker.mjs +58 -19
- package/src/env.mjs +9 -6
- package/src/main.mjs +17 -6
- package/src/model.d.ts +1 -1
- package/src/tools/patchFile.mjs +11 -12
- package/src/usageStore.mjs +167 -0
- package/src/utils/notify.mjs +3 -2
- package/src/voiceInput.mjs +24 -634
- package/src/voiceInputGemini.mjs +105 -0
- package/src/voiceInputOpenAI.mjs +104 -0
- package/src/voiceInputSession.mjs +543 -0
- package/src/voiceToggleKey.mjs +62 -0
- package/bin/plain-notify-terminal-bell +0 -3
package/src/cliCost.mjs
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { UsageRecord } from "./usageStore.mjs"
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import * as usageStore from "./usageStore.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} CostPeriod
|
|
10
|
+
* @property {string} from - YYYY-MM-DD (inclusive, local date)
|
|
11
|
+
* @property {string} to - YYYY-MM-DD (inclusive, local date)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} DailyEntry
|
|
16
|
+
* @property {string} date - YYYY-MM-DD
|
|
17
|
+
* @property {number} totalCost
|
|
18
|
+
* @property {number} sessionCount
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} CurrencyAggregation
|
|
23
|
+
* @property {string} currency
|
|
24
|
+
* @property {DailyEntry[]} daily - sorted by date ascending
|
|
25
|
+
* @property {number} totalCost
|
|
26
|
+
* @property {number} sessionCount
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} CostReport
|
|
31
|
+
* @property {CostPeriod} period
|
|
32
|
+
* @property {CurrencyAggregation[]} byCurrency - sorted by currency
|
|
33
|
+
* @property {number} noPricingSessionCount - sessions without cost data
|
|
34
|
+
* @property {number} excludedOutOfRange - records dropped (out of period)
|
|
35
|
+
* @property {number} totalRecords - records considered (before filtering)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compute the default period: first day of current month (local) through today (local).
|
|
40
|
+
* @param {Date} [now]
|
|
41
|
+
* @returns {CostPeriod}
|
|
42
|
+
*/
|
|
43
|
+
export function defaultPeriod(now = new Date()) {
|
|
44
|
+
const y = now.getFullYear();
|
|
45
|
+
const m = now.getMonth();
|
|
46
|
+
const firstOfMonth = new Date(y, m, 1);
|
|
47
|
+
return {
|
|
48
|
+
from: formatLocalDate(firstOfMonth),
|
|
49
|
+
to: formatLocalDate(now),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Format a Date as YYYY-MM-DD in local time.
|
|
55
|
+
* @param {Date} date
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function formatLocalDate(date) {
|
|
59
|
+
const y = date.getFullYear();
|
|
60
|
+
const m = `${date.getMonth() + 1}`.padStart(2, "0");
|
|
61
|
+
const d = `${date.getDate()}`.padStart(2, "0");
|
|
62
|
+
return `${y}-${m}-${d}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse and validate a YYYY-MM-DD string, returning a Date at local midnight.
|
|
67
|
+
* @param {string} value
|
|
68
|
+
* @returns {Date}
|
|
69
|
+
*/
|
|
70
|
+
export function parseDateOnly(value) {
|
|
71
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
72
|
+
if (!match) {
|
|
73
|
+
throw new Error(`Invalid date: "${value}" (expected YYYY-MM-DD)`);
|
|
74
|
+
}
|
|
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
|
+
if (
|
|
81
|
+
date.getFullYear() !== y ||
|
|
82
|
+
date.getMonth() !== m - 1 ||
|
|
83
|
+
date.getDate() !== d
|
|
84
|
+
) {
|
|
85
|
+
throw new Error(`Invalid date: "${value}"`);
|
|
86
|
+
}
|
|
87
|
+
return date;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Aggregate usage records into a cost report.
|
|
92
|
+
*
|
|
93
|
+
* @param {UsageRecord[]} records
|
|
94
|
+
* @param {CostPeriod} period
|
|
95
|
+
* @returns {CostReport}
|
|
96
|
+
*/
|
|
97
|
+
export function aggregateUsage(records, period) {
|
|
98
|
+
const fromDate = parseDateOnly(period.from);
|
|
99
|
+
const toDate = parseDateOnly(period.to);
|
|
100
|
+
if (fromDate.getTime() > toDate.getTime()) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`"from" (${period.from}) must be on or before "to" (${period.to}).`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @type {Map<string, Map<string, DailyEntry>>} */
|
|
107
|
+
const byCurrency = new Map();
|
|
108
|
+
let noPricingSessionCount = 0;
|
|
109
|
+
let excludedOutOfRange = 0;
|
|
110
|
+
|
|
111
|
+
for (const record of records) {
|
|
112
|
+
if (record.timestamp == null) {
|
|
113
|
+
excludedOutOfRange++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const recordedAt = new Date(record.timestamp);
|
|
117
|
+
if (Number.isNaN(recordedAt.getTime())) {
|
|
118
|
+
excludedOutOfRange++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const localDate = formatLocalDate(recordedAt);
|
|
122
|
+
if (localDate < period.from || localDate > period.to) {
|
|
123
|
+
excludedOutOfRange++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (record.totalCost === null) {
|
|
127
|
+
noPricingSessionCount++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!record.currency || typeof record.currency !== "string") {
|
|
131
|
+
excludedOutOfRange++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const perDate = byCurrency.get(record.currency) ?? new Map();
|
|
136
|
+
byCurrency.set(record.currency, perDate);
|
|
137
|
+
const existing = perDate.get(localDate);
|
|
138
|
+
if (existing) {
|
|
139
|
+
existing.totalCost += record.totalCost;
|
|
140
|
+
existing.sessionCount += 1;
|
|
141
|
+
} else {
|
|
142
|
+
perDate.set(localDate, {
|
|
143
|
+
date: localDate,
|
|
144
|
+
totalCost: record.totalCost,
|
|
145
|
+
sessionCount: 1,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @type {CurrencyAggregation[]} */
|
|
151
|
+
const aggregations = [];
|
|
152
|
+
for (const [currency, perDate] of byCurrency) {
|
|
153
|
+
const daily = Array.from(perDate.values()).sort((a, b) =>
|
|
154
|
+
a.date.localeCompare(b.date),
|
|
155
|
+
);
|
|
156
|
+
let totalCost = 0;
|
|
157
|
+
let sessionCount = 0;
|
|
158
|
+
for (const entry of daily) {
|
|
159
|
+
totalCost += entry.totalCost;
|
|
160
|
+
sessionCount += entry.sessionCount;
|
|
161
|
+
}
|
|
162
|
+
aggregations.push({ currency, daily, totalCost, sessionCount });
|
|
163
|
+
}
|
|
164
|
+
aggregations.sort((a, b) => a.currency.localeCompare(b.currency));
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
period,
|
|
168
|
+
byCurrency: aggregations,
|
|
169
|
+
noPricingSessionCount,
|
|
170
|
+
excludedOutOfRange,
|
|
171
|
+
totalRecords: records.length,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {number} count
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
function formatSessions(count) {
|
|
180
|
+
return `${count} session${count === 1 ? "" : "s"}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render a cost report as a human-readable string.
|
|
185
|
+
*
|
|
186
|
+
* @param {CostReport} report
|
|
187
|
+
* @param {{ color?: boolean }} [options]
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
export function formatCostReport(report, options = {}) {
|
|
191
|
+
const color = options.color ?? true;
|
|
192
|
+
/** @param {string | string[]} _modifiers @param {string} text @returns {string} */
|
|
193
|
+
const plainStyle = (_modifiers, text) => text;
|
|
194
|
+
const style = color ? styleText : plainStyle;
|
|
195
|
+
|
|
196
|
+
const lines = [];
|
|
197
|
+
lines.push(
|
|
198
|
+
style("bold", `Period: ${report.period.from} to ${report.period.to}`),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (report.byCurrency.length === 0) {
|
|
202
|
+
lines.push("");
|
|
203
|
+
lines.push(style("gray", "No usage recorded in this period."));
|
|
204
|
+
if (report.noPricingSessionCount > 0) {
|
|
205
|
+
lines.push(
|
|
206
|
+
style(
|
|
207
|
+
"gray",
|
|
208
|
+
`(${report.noPricingSessionCount} session(s) had no pricing configuration)`,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const agg of report.byCurrency) {
|
|
216
|
+
lines.push("");
|
|
217
|
+
lines.push(style("bold", `Daily cost (${agg.currency}):`));
|
|
218
|
+
for (const entry of agg.daily) {
|
|
219
|
+
lines.push(
|
|
220
|
+
` ${entry.date} ${formatCost(entry.totalCost)} ${agg.currency} (${formatSessions(entry.sessionCount)})`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
lines.push("");
|
|
224
|
+
lines.push(
|
|
225
|
+
style(
|
|
226
|
+
"bold",
|
|
227
|
+
`Total: ${formatCost(agg.totalCost)} ${agg.currency} (${formatSessions(agg.sessionCount)})`,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (report.noPricingSessionCount > 0) {
|
|
233
|
+
lines.push("");
|
|
234
|
+
lines.push(
|
|
235
|
+
style(
|
|
236
|
+
"gray",
|
|
237
|
+
`Note: ${report.noPricingSessionCount} session(s) had no pricing configuration and are excluded from totals.`,
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Format a cost value with 4 decimals.
|
|
247
|
+
* @param {number} value
|
|
248
|
+
* @returns {string}
|
|
249
|
+
*/
|
|
250
|
+
function formatCost(value) {
|
|
251
|
+
return value.toFixed(4);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Run the `plain cost` subcommand.
|
|
256
|
+
*
|
|
257
|
+
* @param {{ from: string | null, to: string | null }} args
|
|
258
|
+
* @param {{ readUsageRecords?: typeof import("./usageStore.mjs").readUsageRecords }} [deps]
|
|
259
|
+
* @returns {Promise<number>} exit code
|
|
260
|
+
*/
|
|
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
|
+
}
|
|
274
|
+
|
|
275
|
+
const { records, skipped } = await (
|
|
276
|
+
deps.readUsageRecords ?? usageStore.readUsageRecords
|
|
277
|
+
)();
|
|
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` : "";
|
|
285
|
+
console.error(
|
|
286
|
+
`Warning: skipped ${skipped.length} malformed line(s) in usage log (${details}${ellipsis}).`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const report = aggregateUsage(records, { from, to });
|
|
291
|
+
console.log(formatCostReport(report));
|
|
292
|
+
return skipped.length > 0 ? 1 : 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolve a period from CLI arguments, falling back to the current month.
|
|
297
|
+
*
|
|
298
|
+
* @param {{ from: string | null, to: string | null }} args
|
|
299
|
+
* @returns {CostPeriod}
|
|
300
|
+
*/
|
|
301
|
+
export function resolvePeriod(args) {
|
|
302
|
+
const fallback = defaultPeriod();
|
|
303
|
+
const from = args.from ?? fallback.from;
|
|
304
|
+
const to = args.to ?? fallback.to;
|
|
305
|
+
// Validate format (throws on invalid input).
|
|
306
|
+
parseDateOnly(from);
|
|
307
|
+
parseDateOnly(to);
|
|
308
|
+
return { from, to };
|
|
309
|
+
}
|
package/src/cliFormatter.mjs
CHANGED
|
@@ -91,7 +91,7 @@ export function formatToolUse(toolUse) {
|
|
|
91
91
|
const diffs = [];
|
|
92
92
|
const matches = Array.from(
|
|
93
93
|
diff.matchAll(
|
|
94
|
-
|
|
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) {
|
package/src/cliInteractive.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { createInterruptTransform } from "./cliInterruptTransform.mjs";
|
|
17
17
|
import { createMuteTransform } from "./cliMuteTransform.mjs";
|
|
18
18
|
import { createPasteHandler } from "./cliPasteTransform.mjs";
|
|
19
|
+
import { appendUsageRecord, buildUsageRecord } from "./usageStore.mjs";
|
|
19
20
|
import { notify } from "./utils/notify.mjs";
|
|
20
21
|
import { parseVoiceToggleKey, startVoiceSession } from "./voiceInput.mjs";
|
|
21
22
|
|
|
@@ -56,13 +57,39 @@ const HELP_MESSAGE = [
|
|
|
56
57
|
* @property {AgentCommands} agentCommands
|
|
57
58
|
* @property {string} sessionId
|
|
58
59
|
* @property {string} modelName
|
|
59
|
-
* @property {string} notifyCmd
|
|
60
|
+
* @property {{ command: string; args?: string[] } | undefined} notifyCmd
|
|
60
61
|
* @property {boolean} sandbox
|
|
61
62
|
* @property {() => Promise<void>} onStop
|
|
62
63
|
* @property {ClaudeCodePlugin[]} [claudeCodePlugins]
|
|
63
64
|
* @property {VoiceInputConfig} [voiceInput]
|
|
64
65
|
*/
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Persist the session's cost summary to the usage log.
|
|
69
|
+
* Failures are logged but never thrown so exit is not blocked.
|
|
70
|
+
*
|
|
71
|
+
* @param {import("./costTracker.mjs").CostSummary} summary
|
|
72
|
+
* @param {{ sessionId: string, modelName: string }} meta
|
|
73
|
+
*/
|
|
74
|
+
async function persistUsage(summary, { sessionId, modelName }) {
|
|
75
|
+
try {
|
|
76
|
+
const record = buildUsageRecord({
|
|
77
|
+
sessionId,
|
|
78
|
+
mode: "interactive",
|
|
79
|
+
modelName,
|
|
80
|
+
workingDir: process.cwd(),
|
|
81
|
+
costSummary: summary,
|
|
82
|
+
});
|
|
83
|
+
if (!record) return;
|
|
84
|
+
await appendUsageRecord(record);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
87
|
+
console.error(
|
|
88
|
+
styleText("yellow", `Warning: failed to record usage: ${message}`),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
/**
|
|
67
94
|
* @param {CliOptions} options
|
|
68
95
|
*/
|
|
@@ -126,6 +153,7 @@ export function startInteractiveSession({
|
|
|
126
153
|
const summary = agentCommands.getCostSummary();
|
|
127
154
|
console.log();
|
|
128
155
|
console.log(formatCostSummary(summary));
|
|
156
|
+
await persistUsage(summary, { sessionId, modelName });
|
|
129
157
|
await onStop();
|
|
130
158
|
process.exit(0);
|
|
131
159
|
};
|
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
|
|
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
|
|
96
|
+
notifyCmd: config.notifyCmd ?? merged.notifyCmd,
|
|
97
97
|
claudeCodePlugins: [
|
|
98
98
|
...(merged.claudeCodePlugins ?? []),
|
|
99
99
|
...(config.claudeCodePlugins ?? []),
|
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
|
@@ -12,6 +12,15 @@ export const AGENT_USER_CONFIG_DIR = path.join(
|
|
|
12
12
|
);
|
|
13
13
|
export const AGENT_CACHE_DIR = path.join(os.homedir(), ".cache", "plain-agent");
|
|
14
14
|
|
|
15
|
+
export const AGENT_DATA_DIR = path.join(
|
|
16
|
+
os.homedir(),
|
|
17
|
+
".local",
|
|
18
|
+
"share",
|
|
19
|
+
"plain-agent",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export const USAGE_LOG_PATH = path.join(AGENT_DATA_DIR, "usage.jsonl");
|
|
23
|
+
|
|
15
24
|
export const TRUSTED_CONFIG_HASHES_DIR = path.join(
|
|
16
25
|
AGENT_CACHE_DIR,
|
|
17
26
|
"trusted-config-hashes",
|
|
@@ -32,10 +41,4 @@ export const MESSAGES_DUMP_FILE_PATH = path.join(
|
|
|
32
41
|
"messages.json",
|
|
33
42
|
);
|
|
34
43
|
|
|
35
|
-
export const AGENT_NOTIFY_CMD_DEFAULT = path.join(
|
|
36
|
-
AGENT_ROOT,
|
|
37
|
-
"bin",
|
|
38
|
-
"plain-notify-terminal-bell",
|
|
39
|
-
);
|
|
40
|
-
|
|
41
44
|
export const USER_NAME = process.env.USER || "unknown";
|
package/src/main.mjs
CHANGED
|
@@ -10,15 +10,12 @@ import {
|
|
|
10
10
|
} from "./claudeCodePlugin.mjs";
|
|
11
11
|
import { parseCliArgs, printHelp } from "./cliArgs.mjs";
|
|
12
12
|
import { startBatchSession } from "./cliBatch.mjs";
|
|
13
|
+
import { runCostCommand } from "./cliCost.mjs";
|
|
13
14
|
import { startInteractiveSession } from "./cliInteractive.mjs";
|
|
14
15
|
import { loadAppConfig } from "./config.mjs";
|
|
15
16
|
import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
|
|
16
17
|
import { loadPrompts } from "./context/loadPrompts.mjs";
|
|
17
|
-
import {
|
|
18
|
-
AGENT_NOTIFY_CMD_DEFAULT,
|
|
19
|
-
AGENT_PROJECT_METADATA_DIR,
|
|
20
|
-
USER_NAME,
|
|
21
|
-
} from "./env.mjs";
|
|
18
|
+
import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
|
|
22
19
|
import { setupMCPServer } from "./mcp.mjs";
|
|
23
20
|
import { createModelCaller } from "./modelCaller.mjs";
|
|
24
21
|
import { createPrompt } from "./prompt.mjs";
|
|
@@ -58,6 +55,20 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
|
|
|
58
55
|
process.exit(0);
|
|
59
56
|
}
|
|
60
57
|
|
|
58
|
+
if (cliArgs.subcommand.type === "cost") {
|
|
59
|
+
try {
|
|
60
|
+
const exitCode = await runCostCommand({
|
|
61
|
+
from: cliArgs.subcommand.from,
|
|
62
|
+
to: cliArgs.subcommand.to,
|
|
63
|
+
});
|
|
64
|
+
process.exit(exitCode);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
+
console.error(message);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
(async () => {
|
|
62
73
|
const startTime = new Date();
|
|
63
74
|
const sessionId = [
|
|
@@ -255,7 +266,7 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
|
|
|
255
266
|
} else {
|
|
256
267
|
startInteractiveSession({
|
|
257
268
|
...sessionOptions,
|
|
258
|
-
notifyCmd: appConfig.notifyCmd
|
|
269
|
+
notifyCmd: appConfig.notifyCmd,
|
|
259
270
|
claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
|
|
260
271
|
voiceInput: appConfig.voiceInput,
|
|
261
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;
|