@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
|
@@ -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
|
+
}
|
package/src/utils/notify.mjs
CHANGED
|
@@ -2,16 +2,17 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { noThrowSync } from "./noThrow.mjs";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* @param {string
|
|
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(
|
|
15
|
+
execFileSync(notifyCmd.command, notifyCmd.args ?? [], {
|
|
15
16
|
shell: false,
|
|
16
17
|
stdio: ["ignore", "inherit", "pipe"],
|
|
17
18
|
env: {
|