@iinm/plain-agent 1.7.18 → 1.7.19
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 +11 -0
- package/package.json +1 -1
- package/src/cliArgs.mjs +31 -1
- package/src/cliBatch.mjs +22 -0
- package/src/cliCost.mjs +274 -0
- package/src/cliInteractive.mjs +28 -0
- package/src/env.mjs +9 -0
- package/src/main.mjs +15 -0
- package/src/usageStore.mjs +167 -0
- package/src/voiceInput.mjs +24 -634
- package/src/voiceInputGemini.mjs +257 -0
- package/src/voiceInputOpenAI.mjs +261 -0
- package/src/voiceInputSession.mjs +250 -0
- package/src/voiceToggleKey.mjs +62 -0
package/README.md
CHANGED
|
@@ -307,6 +307,17 @@ Display the help message.
|
|
|
307
307
|
/help
|
|
308
308
|
```
|
|
309
309
|
|
|
310
|
+
Show aggregated token cost per day across sessions.
|
|
311
|
+
Each finished session appends a record to `~/.local/share/plain-agent/usage.jsonl`,
|
|
312
|
+
and `plain cost` reads that log. The period defaults to the first day of the
|
|
313
|
+
current month through today; override it with `--from` / `--to`. Multiple
|
|
314
|
+
currencies (e.g., USD and JPY) are aggregated separately.
|
|
315
|
+
|
|
316
|
+
```sh
|
|
317
|
+
plain cost
|
|
318
|
+
plain cost --from 2026-04-01 --to 2026-04-30
|
|
319
|
+
```
|
|
320
|
+
|
|
310
321
|
Interrupt the agent while it's running:
|
|
311
322
|
|
|
312
323
|
Press **Ctrl-C** to pause auto-approve. The agent will finish the current tool call, then return to the prompt.
|
package/package.json
CHANGED
package/src/cliArgs.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @typedef {HelpSubcommand | InteractiveSubcommand | BatchSubcommand | ListModelsSubcommand | InstallClaudeCodePluginsSubcommand} Subcommand
|
|
2
|
+
* @typedef {HelpSubcommand | InteractiveSubcommand | BatchSubcommand | ListModelsSubcommand | InstallClaudeCodePluginsSubcommand | CostSubcommand} Subcommand
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
* @typedef {{ type: 'install-claude-code-plugins' }} InstallClaudeCodePluginsSubcommand
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ type: 'cost', from: string | null, to: string | null }} CostSubcommand
|
|
27
|
+
*/
|
|
28
|
+
|
|
25
29
|
/**
|
|
26
30
|
* @typedef {Object} CliArgs
|
|
27
31
|
* @property {Subcommand} subcommand - The subcommand to execute
|
|
@@ -106,6 +110,28 @@ export function parseCliArgs(argv) {
|
|
|
106
110
|
};
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
if (subcommandName === "cost") {
|
|
114
|
+
const costArgs = args.slice(1);
|
|
115
|
+
let from = null;
|
|
116
|
+
let to = null;
|
|
117
|
+
for (let i = 0; i < costArgs.length; i++) {
|
|
118
|
+
if (costArgs[i] === "--from") {
|
|
119
|
+
if (costArgs[i + 1]) {
|
|
120
|
+
from = costArgs[i + 1];
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
} else if (costArgs[i] === "--to") {
|
|
124
|
+
if (costArgs[i + 1]) {
|
|
125
|
+
to = costArgs[i + 1];
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
subcommand: { type: "cost", from, to },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
109
135
|
return {
|
|
110
136
|
subcommand: { type: "help" },
|
|
111
137
|
};
|
|
@@ -119,6 +145,7 @@ export function printHelp(exitCode = 0) {
|
|
|
119
145
|
console.log(`
|
|
120
146
|
Usage: plain [options]
|
|
121
147
|
plain batch [options] <task>
|
|
148
|
+
plain cost [--from YYYY-MM-DD] [--to YYYY-MM-DD]
|
|
122
149
|
plain list-models
|
|
123
150
|
plain install-claude-code-plugins
|
|
124
151
|
|
|
@@ -131,6 +158,9 @@ Subcommands:
|
|
|
131
158
|
batch <task> Run in batch mode with the given task instruction.
|
|
132
159
|
Config files are NOT auto-loaded in batch mode;
|
|
133
160
|
use -c to specify config files explicitly.
|
|
161
|
+
cost Show aggregated token cost per day for a period.
|
|
162
|
+
Defaults to the first day of the current month
|
|
163
|
+
through today.
|
|
134
164
|
list-models List available models
|
|
135
165
|
install-claude-code-plugins Install Claude Code plugins
|
|
136
166
|
|
package/src/cliBatch.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { formatCostForBatch } from "./cliFormatter.mjs";
|
|
6
|
+
import { appendUsageRecord, buildUsageRecord } from "./usageStore.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* @typedef {object} BatchSessionOptions
|
|
@@ -46,6 +47,27 @@ export async function startBatchSession({
|
|
|
46
47
|
timestamp: new Date().toISOString(),
|
|
47
48
|
cost: formatCostForBatch(costSummary),
|
|
48
49
|
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const record = buildUsageRecord({
|
|
53
|
+
sessionId,
|
|
54
|
+
mode: "batch",
|
|
55
|
+
modelName,
|
|
56
|
+
workingDir: process.cwd(),
|
|
57
|
+
costSummary,
|
|
58
|
+
});
|
|
59
|
+
if (record) {
|
|
60
|
+
await appendUsageRecord(record);
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
64
|
+
outputEvent({
|
|
65
|
+
type: "error",
|
|
66
|
+
error: { message: `failed to record usage: ${message}` },
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
await onStop();
|
|
50
72
|
resolve();
|
|
51
73
|
});
|
package/src/cliCost.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { UsageRecord } from "./usageStore.mjs"
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { readUsageRecords } 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 [, 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);
|
|
80
|
+
if (
|
|
81
|
+
date.getFullYear() !== year ||
|
|
82
|
+
date.getMonth() !== month - 1 ||
|
|
83
|
+
date.getDate() !== day
|
|
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
|
+
const recordedAt = new Date(record.timestamp);
|
|
113
|
+
if (Number.isNaN(recordedAt.getTime())) {
|
|
114
|
+
excludedOutOfRange++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const localDate = formatLocalDate(recordedAt);
|
|
118
|
+
if (localDate < period.from || localDate > period.to) {
|
|
119
|
+
excludedOutOfRange++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (record.totalCost === null) {
|
|
123
|
+
noPricingSessionCount++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let perDate = byCurrency.get(record.currency);
|
|
128
|
+
if (!perDate) {
|
|
129
|
+
perDate = new Map();
|
|
130
|
+
byCurrency.set(record.currency, perDate);
|
|
131
|
+
}
|
|
132
|
+
const existing = perDate.get(localDate);
|
|
133
|
+
if (existing) {
|
|
134
|
+
existing.totalCost += record.totalCost;
|
|
135
|
+
existing.sessionCount += 1;
|
|
136
|
+
} else {
|
|
137
|
+
perDate.set(localDate, {
|
|
138
|
+
date: localDate,
|
|
139
|
+
totalCost: record.totalCost,
|
|
140
|
+
sessionCount: 1,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @type {CurrencyAggregation[]} */
|
|
146
|
+
const aggregations = [];
|
|
147
|
+
for (const [currency, perDate] of byCurrency) {
|
|
148
|
+
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,
|
|
155
|
+
);
|
|
156
|
+
aggregations.push({ currency, daily, totalCost, sessionCount });
|
|
157
|
+
}
|
|
158
|
+
aggregations.sort((a, b) => a.currency.localeCompare(b.currency));
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
period,
|
|
162
|
+
byCurrency: aggregations,
|
|
163
|
+
noPricingSessionCount,
|
|
164
|
+
excludedOutOfRange,
|
|
165
|
+
totalRecords: records.length,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Render a cost report as a human-readable string.
|
|
171
|
+
*
|
|
172
|
+
* @param {CostReport} report
|
|
173
|
+
* @param {{ color?: boolean }} [options]
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function formatCostReport(report, options = {}) {
|
|
177
|
+
const color = options.color ?? true;
|
|
178
|
+
const style = color
|
|
179
|
+
? styleText
|
|
180
|
+
: /** @param {any} _modifiers @param {string} text */ (_modifiers, text) =>
|
|
181
|
+
text;
|
|
182
|
+
|
|
183
|
+
const lines = [];
|
|
184
|
+
lines.push(
|
|
185
|
+
style("bold", `Period: ${report.period.from} to ${report.period.to}`),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (report.byCurrency.length === 0) {
|
|
189
|
+
lines.push("");
|
|
190
|
+
lines.push(style("gray", "No usage recorded in this period."));
|
|
191
|
+
if (report.noPricingSessionCount > 0) {
|
|
192
|
+
lines.push(
|
|
193
|
+
style(
|
|
194
|
+
"gray",
|
|
195
|
+
`(${report.noPricingSessionCount} session(s) had no pricing configuration)`,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const agg of report.byCurrency) {
|
|
203
|
+
lines.push("");
|
|
204
|
+
lines.push(style("bold", `Daily cost (${agg.currency}):`));
|
|
205
|
+
for (const entry of agg.daily) {
|
|
206
|
+
lines.push(
|
|
207
|
+
` ${entry.date} ${formatCost(entry.totalCost)} ${agg.currency} (${entry.sessionCount} session${entry.sessionCount === 1 ? "" : "s"})`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
lines.push("");
|
|
211
|
+
lines.push(
|
|
212
|
+
style(
|
|
213
|
+
"bold",
|
|
214
|
+
`Total: ${formatCost(agg.totalCost)} ${agg.currency} (${agg.sessionCount} session${agg.sessionCount === 1 ? "" : "s"})`,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (report.noPricingSessionCount > 0) {
|
|
220
|
+
lines.push("");
|
|
221
|
+
lines.push(
|
|
222
|
+
style(
|
|
223
|
+
"gray",
|
|
224
|
+
`Note: ${report.noPricingSessionCount} session(s) had no pricing configuration and are excluded from totals.`,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Format a cost value with 4 decimals.
|
|
234
|
+
* @param {number} value
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
function formatCost(value) {
|
|
238
|
+
return value.toFixed(4);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run the `plain cost` subcommand.
|
|
243
|
+
*
|
|
244
|
+
* @param {{ from: string | null, to: string | null }} args
|
|
245
|
+
* @returns {Promise<number>} exit code
|
|
246
|
+
*/
|
|
247
|
+
export async function runCostCommand(args) {
|
|
248
|
+
const { from, to } = resolvePeriod(args);
|
|
249
|
+
|
|
250
|
+
const { records, skipped } = await readUsageRecords();
|
|
251
|
+
if (skipped.length > 0) {
|
|
252
|
+
console.error(
|
|
253
|
+
`Warning: skipped ${skipped.length} malformed line(s) in usage log.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const report = aggregateUsage(records, { from, to });
|
|
258
|
+
console.log(formatCostReport(report));
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {{ from: string | null, to: string | null }} args
|
|
264
|
+
* @returns {CostPeriod}
|
|
265
|
+
*/
|
|
266
|
+
function resolvePeriod(args) {
|
|
267
|
+
const fallback = defaultPeriod();
|
|
268
|
+
const from = args.from ?? fallback.from;
|
|
269
|
+
const to = args.to ?? fallback.to;
|
|
270
|
+
// Validate format (throws on invalid input).
|
|
271
|
+
parseDateOnly(from);
|
|
272
|
+
parseDateOnly(to);
|
|
273
|
+
return { from, to };
|
|
274
|
+
}
|
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
|
|
|
@@ -63,6 +64,32 @@ const HELP_MESSAGE = [
|
|
|
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/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",
|
package/src/main.mjs
CHANGED
|
@@ -10,6 +10,7 @@ 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";
|
|
@@ -58,6 +59,20 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
|
|
|
58
59
|
process.exit(0);
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
if (cliArgs.subcommand.type === "cost") {
|
|
63
|
+
try {
|
|
64
|
+
const exitCode = await runCostCommand({
|
|
65
|
+
from: cliArgs.subcommand.from,
|
|
66
|
+
to: cliArgs.subcommand.to,
|
|
67
|
+
});
|
|
68
|
+
process.exit(exitCode);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
71
|
+
console.error(message);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
61
76
|
(async () => {
|
|
62
77
|
const startTime = new Date();
|
|
63
78
|
const sessionId = [
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { CostSummary } from "./costTracker.mjs"
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { USAGE_LOG_PATH } from "./env.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} UsageRecord
|
|
11
|
+
* @property {string} timestamp - ISO 8601 timestamp
|
|
12
|
+
* @property {string} sessionId
|
|
13
|
+
* @property {"interactive" | "batch"} mode
|
|
14
|
+
* @property {string} modelName - e.g. "claude-sonnet-4-6+thinking-high"
|
|
15
|
+
* @property {string} workingDir
|
|
16
|
+
* @property {string} currency - e.g. "USD"
|
|
17
|
+
* @property {string} unit - e.g. "1M"
|
|
18
|
+
* @property {number | null} totalCost - null when no pricing configured
|
|
19
|
+
* @property {Record<string, number>} tokens - aggregated token counts by path
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Maximum size (in bytes) of a single JSONL line.
|
|
24
|
+
* Linux guarantees atomicity of O_APPEND writes up to PIPE_BUF (4096 bytes),
|
|
25
|
+
* so we enforce a smaller limit to stay safely under that threshold even
|
|
26
|
+
* with multi-byte UTF-8 characters in model/session names.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_RECORD_BYTES = 3072;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Append a usage record to the persistent usage log.
|
|
32
|
+
*
|
|
33
|
+
* On POSIX systems, `fs.appendFile` opens the file with `O_APPEND`, which
|
|
34
|
+
* guarantees that each write lands at end-of-file and is atomic when the
|
|
35
|
+
* payload is <= PIPE_BUF (4096 bytes on Linux). We write the record as a
|
|
36
|
+
* single call to preserve this guarantee even if multiple plain-agent
|
|
37
|
+
* sessions finish simultaneously.
|
|
38
|
+
*
|
|
39
|
+
* @param {UsageRecord} record
|
|
40
|
+
* @param {{ path?: string }} [options]
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
export async function appendUsageRecord(record, options = {}) {
|
|
44
|
+
const filePath = options.path ?? USAGE_LOG_PATH;
|
|
45
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
46
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
47
|
+
if (bytes > MAX_RECORD_BYTES) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Usage record exceeds ${MAX_RECORD_BYTES} bytes (${bytes}); refusing to write to keep appends atomic.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
53
|
+
await fs.appendFile(filePath, line, { encoding: "utf8" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read all usage records from the log file.
|
|
58
|
+
* Malformed lines are skipped and collected in `skipped`.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ path?: string }} [options]
|
|
61
|
+
* @returns {Promise<{ records: UsageRecord[], skipped: { line: number, reason: string }[] }>}
|
|
62
|
+
*/
|
|
63
|
+
export async function readUsageRecords(options = {}) {
|
|
64
|
+
const filePath = options.path ?? USAGE_LOG_PATH;
|
|
65
|
+
/** @type {string} */
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = await fs.readFile(filePath, "utf8");
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (
|
|
71
|
+
err instanceof Error &&
|
|
72
|
+
/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
|
|
73
|
+
) {
|
|
74
|
+
return { records: [], skipped: [] };
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @type {UsageRecord[]} */
|
|
80
|
+
const records = [];
|
|
81
|
+
/** @type {{ line: number, reason: string }[]} */
|
|
82
|
+
const skipped = [];
|
|
83
|
+
|
|
84
|
+
const lines = content.split("\n");
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const raw = lines[i];
|
|
87
|
+
if (raw.length === 0) continue;
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(raw);
|
|
90
|
+
if (!isUsageRecord(parsed)) {
|
|
91
|
+
skipped.push({ line: i + 1, reason: "invalid shape" });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
records.push(parsed);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
97
|
+
skipped.push({ line: i + 1, reason });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { records, skipped };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a usage record from a finished session's cost summary.
|
|
106
|
+
* Returns null when there's nothing worth recording (no tokens).
|
|
107
|
+
*
|
|
108
|
+
* @param {Object} args
|
|
109
|
+
* @param {string} args.sessionId
|
|
110
|
+
* @param {"interactive" | "batch"} args.mode
|
|
111
|
+
* @param {string} args.modelName
|
|
112
|
+
* @param {string} args.workingDir
|
|
113
|
+
* @param {CostSummary} args.costSummary
|
|
114
|
+
* @param {Date} [args.now]
|
|
115
|
+
* @returns {UsageRecord | null}
|
|
116
|
+
*/
|
|
117
|
+
export function buildUsageRecord({
|
|
118
|
+
sessionId,
|
|
119
|
+
mode,
|
|
120
|
+
modelName,
|
|
121
|
+
workingDir,
|
|
122
|
+
costSummary,
|
|
123
|
+
now,
|
|
124
|
+
}) {
|
|
125
|
+
/** @type {Record<string, number>} */
|
|
126
|
+
const tokens = {};
|
|
127
|
+
for (const [key, entry] of Object.entries(costSummary.breakdown)) {
|
|
128
|
+
tokens[key] = entry.tokens;
|
|
129
|
+
}
|
|
130
|
+
if (Object.keys(tokens).length === 0) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const timestamp = (now ?? new Date()).toISOString();
|
|
134
|
+
return {
|
|
135
|
+
timestamp,
|
|
136
|
+
sessionId,
|
|
137
|
+
mode,
|
|
138
|
+
modelName,
|
|
139
|
+
workingDir,
|
|
140
|
+
currency: costSummary.currency,
|
|
141
|
+
unit: costSummary.unit,
|
|
142
|
+
totalCost:
|
|
143
|
+
costSummary.totalCost === undefined ? null : costSummary.totalCost,
|
|
144
|
+
tokens,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {unknown} value
|
|
150
|
+
* @returns {value is UsageRecord}
|
|
151
|
+
*/
|
|
152
|
+
function isUsageRecord(value) {
|
|
153
|
+
if (typeof value !== "object" || value === null) return false;
|
|
154
|
+
const r = /** @type {Record<string, unknown>} */ (value);
|
|
155
|
+
return (
|
|
156
|
+
typeof r.timestamp === "string" &&
|
|
157
|
+
typeof r.sessionId === "string" &&
|
|
158
|
+
(r.mode === "interactive" || r.mode === "batch") &&
|
|
159
|
+
typeof r.modelName === "string" &&
|
|
160
|
+
typeof r.workingDir === "string" &&
|
|
161
|
+
typeof r.currency === "string" &&
|
|
162
|
+
typeof r.unit === "string" &&
|
|
163
|
+
(r.totalCost === null || typeof r.totalCost === "number") &&
|
|
164
|
+
typeof r.tokens === "object" &&
|
|
165
|
+
r.tokens !== null
|
|
166
|
+
);
|
|
167
|
+
}
|