@iinm/plain-agent 1.7.17 → 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.
@@ -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
+ }
@@ -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
+ }