@iinm/plain-agent 1.8.3 → 1.8.4

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.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/bin/plain +1 -1
  3. package/config/config.predefined.json +1 -1
  4. package/config/prompts.predefined/shortcuts/configure.md +1 -1
  5. package/dist/main.mjs +473 -0
  6. package/dist/main.mjs.map +7 -0
  7. package/package.json +5 -7
  8. package/src/agent.d.ts +0 -52
  9. package/src/agent.mjs +0 -204
  10. package/src/agentLoop.mjs +0 -419
  11. package/src/agentState.mjs +0 -41
  12. package/src/claudeCodePlugin.mjs +0 -164
  13. package/src/cliArgs.mjs +0 -175
  14. package/src/cliBatch.mjs +0 -147
  15. package/src/cliCommands.mjs +0 -283
  16. package/src/cliCompleter.mjs +0 -227
  17. package/src/cliCost.mjs +0 -309
  18. package/src/cliFormatter.mjs +0 -413
  19. package/src/cliInteractive.mjs +0 -529
  20. package/src/cliInterruptTransform.mjs +0 -51
  21. package/src/cliMuteTransform.mjs +0 -26
  22. package/src/cliPasteTransform.mjs +0 -183
  23. package/src/config.d.ts +0 -36
  24. package/src/config.mjs +0 -197
  25. package/src/context/loadAgentRoles.mjs +0 -283
  26. package/src/context/loadPrompts.mjs +0 -324
  27. package/src/context/loadUserMessageContext.mjs +0 -147
  28. package/src/costTracker.mjs +0 -210
  29. package/src/env.mjs +0 -44
  30. package/src/main.mjs +0 -279
  31. package/src/mcpClient.mjs +0 -351
  32. package/src/mcpIntegration.mjs +0 -160
  33. package/src/model.d.ts +0 -109
  34. package/src/modelCaller.mjs +0 -32
  35. package/src/modelDefinition.d.ts +0 -92
  36. package/src/prompt.mjs +0 -138
  37. package/src/providers/anthropic.d.ts +0 -248
  38. package/src/providers/anthropic.mjs +0 -587
  39. package/src/providers/bedrock.d.ts +0 -249
  40. package/src/providers/bedrock.mjs +0 -700
  41. package/src/providers/gemini.d.ts +0 -208
  42. package/src/providers/gemini.mjs +0 -754
  43. package/src/providers/openai.d.ts +0 -281
  44. package/src/providers/openai.mjs +0 -544
  45. package/src/providers/openaiCompatible.d.ts +0 -147
  46. package/src/providers/openaiCompatible.mjs +0 -652
  47. package/src/providers/platform/awsSigV4.mjs +0 -184
  48. package/src/providers/platform/azure.mjs +0 -42
  49. package/src/providers/platform/bedrock.mjs +0 -78
  50. package/src/providers/platform/googleCloud.mjs +0 -34
  51. package/src/subagent.mjs +0 -265
  52. package/src/tmpfile.mjs +0 -27
  53. package/src/tool.d.ts +0 -74
  54. package/src/toolExecutor.mjs +0 -236
  55. package/src/toolInputValidator.mjs +0 -183
  56. package/src/toolUseApprover.mjs +0 -99
  57. package/src/tools/askURL.mjs +0 -209
  58. package/src/tools/askWeb.mjs +0 -208
  59. package/src/tools/compactContext.d.ts +0 -4
  60. package/src/tools/compactContext.mjs +0 -87
  61. package/src/tools/delegateToSubagent.d.ts +0 -4
  62. package/src/tools/delegateToSubagent.mjs +0 -48
  63. package/src/tools/execCommand.d.ts +0 -22
  64. package/src/tools/execCommand.mjs +0 -200
  65. package/src/tools/patchFile.d.ts +0 -4
  66. package/src/tools/patchFile.mjs +0 -133
  67. package/src/tools/reportAsSubagent.d.ts +0 -3
  68. package/src/tools/reportAsSubagent.mjs +0 -44
  69. package/src/tools/tmuxCommand.d.ts +0 -14
  70. package/src/tools/tmuxCommand.mjs +0 -194
  71. package/src/tools/writeFile.d.ts +0 -4
  72. package/src/tools/writeFile.mjs +0 -56
  73. package/src/usageStore.mjs +0 -167
  74. package/src/utils/evalJSONConfig.mjs +0 -72
  75. package/src/utils/matchValue.d.ts +0 -6
  76. package/src/utils/matchValue.mjs +0 -40
  77. package/src/utils/noThrow.mjs +0 -31
  78. package/src/utils/notify.mjs +0 -29
  79. package/src/utils/parseFileRange.mjs +0 -18
  80. package/src/utils/readFileRange.mjs +0 -33
  81. package/src/utils/retryOnError.mjs +0 -41
  82. package/src/voiceInput.mjs +0 -61
  83. package/src/voiceInputGemini.mjs +0 -105
  84. package/src/voiceInputOpenAI.mjs +0 -104
  85. package/src/voiceInputSession.mjs +0 -543
  86. package/src/voiceToggleKey.mjs +0 -62
@@ -1,324 +0,0 @@
1
- /** @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs" */
2
-
3
- import { execFileSync } from "node:child_process";
4
- import crypto from "node:crypto";
5
- import fs from "node:fs/promises";
6
- import path from "node:path";
7
- import { parse as parseYaml } from "yaml";
8
- import {
9
- AGENT_CACHE_DIR,
10
- AGENT_PROJECT_METADATA_DIR,
11
- AGENT_ROOT,
12
- AGENT_USER_CONFIG_DIR,
13
- } from "../env.mjs";
14
-
15
- /**
16
- * @typedef {Object} Prompt
17
- * @property {string} id
18
- * @property {string} description
19
- * @property {string} content
20
- * @property {string} filePath
21
- * @property {boolean} claudeOriginated
22
- * @property {string} [import]
23
- * @property {boolean} [userInvocable]
24
- * @property {boolean} [isShortcut]
25
- * @property {boolean} [isSkill]
26
- */
27
-
28
- /**
29
- * Load all prompts from the predefined directories.
30
- * @param {ClaudeCodePlugin[]} [claudeCodePlugins]
31
- * @returns {Promise<Map<string, Prompt>>}
32
- */
33
- export async function loadPrompts(claudeCodePlugins) {
34
- /** @type {Array<{dir: string, idPrefix: string, only?: RegExp}>} */
35
- const promptDirs = [
36
- {
37
- dir: path.resolve(AGENT_ROOT, "config", "prompts.predefined"),
38
- idPrefix: "",
39
- },
40
- { dir: path.resolve(AGENT_USER_CONFIG_DIR, "prompts"), idPrefix: "" },
41
- { dir: path.resolve(AGENT_PROJECT_METADATA_DIR, "prompts"), idPrefix: "" },
42
- {
43
- dir: path.resolve(process.cwd(), ".claude", "commands"),
44
- idPrefix: "claude/commands:",
45
- },
46
- {
47
- dir: path.resolve(process.cwd(), ".claude", "skills"),
48
- idPrefix: "claude/skills:",
49
- },
50
- ];
51
-
52
- // Add plugin directories if provided
53
- if (claudeCodePlugins) {
54
- for (const plugin of claudeCodePlugins) {
55
- // Commands
56
- promptDirs.push({
57
- dir: path.join(plugin.path, "commands"),
58
- idPrefix: `claude/${plugin.name}/commands:`,
59
- only: plugin.only,
60
- });
61
-
62
- // Skills
63
- promptDirs.push({
64
- dir: path.join(plugin.path, "skills"),
65
- idPrefix: `claude/${plugin.name}/skills:`,
66
- only: plugin.only,
67
- });
68
- }
69
- }
70
-
71
- /** @type {Map<string, Prompt>} */
72
- const prompts = new Map();
73
-
74
- for (const { dir, idPrefix, only } of promptDirs) {
75
- const files = await getMarkdownFiles(dir).catch((err) => {
76
- if (err.code !== "ENOENT") {
77
- console.warn(`Failed to list prompts in ${dir}:`, err);
78
- }
79
- return [];
80
- });
81
-
82
- for (const file of files) {
83
- const fullPath = path.join(dir, file);
84
- const content = await fs.readFile(fullPath, "utf-8").catch((err) => {
85
- console.warn(`Failed to read prompt file ${fullPath}:`, err);
86
- return null;
87
- });
88
-
89
- if (content === null) continue;
90
-
91
- // Filter by only pattern if specified
92
- if (only && !only.test(file)) {
93
- continue;
94
- }
95
-
96
- // Ignore all files in the skills/ directory except for SKILL.md.
97
- if (fullPath.match(/\/skills\//) && !file.endsWith("/SKILL.md")) {
98
- continue;
99
- }
100
-
101
- let prompt = parsePrompt(file, content, fullPath, idPrefix);
102
- if (prompt.import) {
103
- prompt = await mergeRemotePrompt(prompt, file, fullPath);
104
- }
105
-
106
- if (prompt.userInvocable === false) {
107
- continue;
108
- }
109
-
110
- prompts.set(prompt.id, prompt);
111
- }
112
- }
113
-
114
- return prompts;
115
- }
116
-
117
- /**
118
- * Merges a remote prompt into a local prompt if an import URL is provided.
119
- * @param {Prompt} localPrompt
120
- * @param {string} relativePath
121
- * @param {string} fullPath
122
- * @returns {Promise<Prompt>}
123
- */
124
- async function mergeRemotePrompt(localPrompt, relativePath, fullPath) {
125
- const importUrl = localPrompt.import;
126
- if (!importUrl) {
127
- return localPrompt;
128
- }
129
-
130
- const fetchedContent = await fetchAndCachePrompt(importUrl).catch((err) => {
131
- console.warn(`Failed to fetch prompt from ${importUrl}:`, err);
132
- return null;
133
- });
134
-
135
- if (!fetchedContent) {
136
- return localPrompt;
137
- }
138
-
139
- const remotePrompt = parsePrompt(relativePath, fetchedContent, fullPath);
140
-
141
- return {
142
- ...remotePrompt,
143
- ...localPrompt, // Local overrides
144
- content: `${remotePrompt.content}\n\n---\n\n${localPrompt.content}`.trim(),
145
- description: localPrompt.description || remotePrompt.description || "",
146
- };
147
- }
148
-
149
- /**
150
- * Fetch a prompt from a URL and cache it.
151
- * @param {string} url
152
- * @returns {Promise<string>}
153
- */
154
- async function fetchAndCachePrompt(url) {
155
- const hash = crypto.createHash("sha256").update(url).digest("hex");
156
- const cacheDir = path.join(AGENT_CACHE_DIR, "prompts");
157
- const cachePath = path.join(cacheDir, hash);
158
-
159
- const cachedContent = await fs.readFile(cachePath, "utf-8").catch(() => null);
160
- if (cachedContent !== null) {
161
- return cachedContent;
162
- }
163
-
164
- const fetchedContent = await fetchContent(url);
165
-
166
- // Attempt to cache, but don't block or fail on errors
167
- fs.mkdir(cacheDir, { recursive: true })
168
- .then(() => fs.writeFile(cachePath, fetchedContent, "utf-8"))
169
- .catch((err) => {
170
- console.warn(`Failed to write cache for ${url}:`, err);
171
- });
172
-
173
- return fetchedContent;
174
- }
175
-
176
- /**
177
- * Fetch content from a URL.
178
- * @param {string} url
179
- * @returns {Promise<string>}
180
- */
181
- async function fetchContent(url) {
182
- const githubMatch = url.match(
183
- /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
184
- );
185
-
186
- if (githubMatch) {
187
- const [, owner, repo, ref, path] = githubMatch;
188
- const apiUrl = `repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
189
- try {
190
- return execFileSync(
191
- "gh",
192
- ["api", "-H", "Accept: application/vnd.github.v3.raw", apiUrl],
193
- { encoding: "utf-8" },
194
- );
195
- } catch (err) {
196
- throw new Error(`Failed to fetch from GitHub via gh CLI: ${err}`);
197
- }
198
- }
199
-
200
- const response = await fetch(url);
201
- if (!response.ok) {
202
- throw new Error(
203
- `Failed to fetch prompt from ${url}: ${response.status} ${response.statusText}`,
204
- );
205
- }
206
- return response.text();
207
- }
208
-
209
- /**
210
- * Recursively get all markdown files in a directory.
211
- * @param {string} dir
212
- * @param {string} [baseDir]
213
- * @returns {Promise<string[]>}
214
- */
215
- async function getMarkdownFiles(dir, baseDir = dir) {
216
- const entries = await fs.readdir(dir, { withFileTypes: true });
217
- const files = [];
218
-
219
- for (const entry of entries) {
220
- const fullPath = path.join(dir, entry.name);
221
- let isDirectory = entry.isDirectory();
222
- let isFile = entry.isFile();
223
-
224
- if (entry.isSymbolicLink()) {
225
- const stat = await fs.stat(fullPath).catch(() => null);
226
- if (!stat) continue;
227
- isDirectory = stat.isDirectory();
228
- isFile = stat.isFile();
229
- }
230
-
231
- if (isDirectory) {
232
- files.push(...(await getMarkdownFiles(fullPath, baseDir)));
233
- } else if (isFile && entry.name.endsWith(".md")) {
234
- files.push(path.relative(baseDir, fullPath));
235
- }
236
- }
237
-
238
- return files;
239
- }
240
-
241
- /**
242
- * Parse a prompt file content.
243
- * @param {string} relativePath
244
- * @param {string} fileContent
245
- * @param {string} fullPath
246
- * @param {string} [idPrefix=""]
247
- * @returns {Prompt}
248
- */
249
- function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
250
- const rawId = relativePath.replace(/\/SKILL\.md$/, "").replace(/\.md$/, "");
251
- const isSkill = relativePath.endsWith("SKILL.md");
252
- const isShortcut = rawId.startsWith("shortcuts/");
253
- const id = isShortcut
254
- ? idPrefix + rawId.replace(/^shortcuts\//, "")
255
- : idPrefix + rawId;
256
- const claudeOriginated = idPrefix.startsWith("claude");
257
-
258
- // Match YAML frontmatter
259
- const match = fileContent.match(
260
- /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/,
261
- );
262
-
263
- if (!match) {
264
- return {
265
- id,
266
- description: "",
267
- content: fileContent.trim(),
268
- filePath: fullPath,
269
- claudeOriginated,
270
- isShortcut,
271
- isSkill,
272
- };
273
- }
274
-
275
- const content = match[2].trim();
276
-
277
- /** @type {{description?:string; import?:string; "user-invocable"?:boolean}} */
278
- let frontmatter;
279
- try {
280
- frontmatter =
281
- /** @type {{description?:string; import?:string; "user-invocable"?:boolean}} */ (
282
- parseYaml(match[1])
283
- );
284
- } catch (_err) {
285
- return {
286
- id,
287
- description: parseFrontmatterField(match[1], "description") ?? "",
288
- content,
289
- filePath: fullPath,
290
- claudeOriginated,
291
- import: parseFrontmatterField(match[1], "import"),
292
- userInvocable:
293
- parseFrontmatterField(match[1], "user-invocable") === "true" ||
294
- undefined,
295
- isShortcut,
296
- isSkill,
297
- };
298
- }
299
- const userInvocable = frontmatter["user-invocable"];
300
-
301
- return {
302
- id,
303
- description: frontmatter.description ?? "",
304
- content,
305
- filePath: fullPath,
306
- claudeOriginated,
307
- import: frontmatter.import,
308
- userInvocable: userInvocable ?? undefined,
309
- isShortcut,
310
- isSkill: relativePath.endsWith("SKILL.md"),
311
- };
312
- }
313
-
314
- /**
315
- * Parse a field from YAML frontmatter.
316
- * @param {string} frontmatter
317
- * @param {string} field
318
- * @returns {string | undefined}
319
- */
320
- function parseFrontmatterField(frontmatter, field) {
321
- const regex = new RegExp(`^${field}:\\s*(.*)$`, "m");
322
- const match = frontmatter.match(regex);
323
- return match ? match[1].trim() : undefined;
324
- }
@@ -1,147 +0,0 @@
1
- /**
2
- * @import { MessageContentText, MessageContentImage } from "../model";
3
- */
4
-
5
- import { readFile } from "node:fs/promises";
6
- import path from "node:path";
7
- import { styleText } from "node:util";
8
- import { noThrow } from "../utils/noThrow.mjs";
9
- import { parseFileRange } from "../utils/parseFileRange.mjs";
10
- import { readFileRange } from "../utils/readFileRange.mjs";
11
-
12
- /** @type {ReadonlyMap<string, string>} */
13
- const IMAGE_MIME_TYPES = new Map([
14
- [".png", "image/png"],
15
- [".jpg", "image/jpeg"],
16
- [".jpeg", "image/jpeg"],
17
- [".gif", "image/gif"],
18
- [".webp", "image/webp"],
19
- ]);
20
-
21
- /**
22
- * @param {string} message
23
- * @returns {Promise<(MessageContentText | MessageContentImage)[]>}
24
- */
25
- export async function loadUserMessageContext(message) {
26
- const workingDir = process.cwd();
27
-
28
- /** @type {string[]} */
29
- const text = [];
30
- /** @type {string[]} */
31
- const contexts = [];
32
- /** @type {MessageContentImage[]} */
33
- const images = [];
34
-
35
- let cursor = 0;
36
- for (const match of message.matchAll(
37
- /(^|\s)@(?:'([^']+)'|((?:\\ |[^\s])+))/g,
38
- )) {
39
- if (cursor < match.index) {
40
- text.push(message.slice(cursor, match.index));
41
- }
42
- cursor = match.index + match[0].length;
43
- const [entireMatch, leading, quoted, escaped] = match;
44
- const reference = quoted ?? escaped.replace(/\\ /g, " ");
45
-
46
- const ext = path.extname(reference).toLowerCase();
47
- if (IMAGE_MIME_TYPES.has(ext)) {
48
- const imageContent = await loadImageContent(reference);
49
- if (imageContent instanceof Error) {
50
- warn(`Failed to load image from ${reference}: ${imageContent.message}`);
51
- text.push(entireMatch);
52
- continue;
53
- }
54
- images.push(imageContent);
55
- text.push(`${leading}[Image #${images.length}:${reference}]`);
56
- continue;
57
- }
58
-
59
- const contextSnippet = await loadContextSnippet(reference, workingDir);
60
- if (contextSnippet) {
61
- contexts.push(contextSnippet);
62
- }
63
- text.push(entireMatch);
64
- }
65
-
66
- if (cursor < message.length) {
67
- text.push(message.slice(cursor));
68
- }
69
-
70
- return [
71
- { type: "text", text: [text.join(""), ...contexts].join("\n\n") },
72
- ...images,
73
- ];
74
- }
75
-
76
- /**
77
- * @param {string} reference
78
- * @param {string} workingDir
79
- * @returns {Promise<string | null>}
80
- */
81
- async function loadContextSnippet(reference, workingDir) {
82
- const fileRange = parseFileRange(reference);
83
- if (fileRange instanceof Error) {
84
- warn(`Failed to parse context reference ${reference}: ${fileRange}`);
85
- return null;
86
- }
87
-
88
- const absolutePath = path.resolve(fileRange.filePath);
89
- const relativePath = path.relative(workingDir, absolutePath);
90
- if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
91
- warn(
92
- `Refusing to load context from outside working directory: ${absolutePath}`,
93
- );
94
- return null;
95
- }
96
-
97
- const fileContent = await readFileRange(fileRange);
98
- if (fileContent instanceof Error) {
99
- warn(`Failed to load context from ${reference}: ${fileContent}`);
100
- return null;
101
- }
102
-
103
- return [`<context location="${reference}">`, fileContent, "</context>"].join(
104
- "\n",
105
- );
106
- }
107
-
108
- /**
109
- * @param {string} imagePath
110
- * @returns {Promise<MessageContentImage | Error>}
111
- */
112
- async function loadImageContent(imagePath) {
113
- const absolutePath = path.resolve(imagePath);
114
-
115
- return await noThrow(async () => {
116
- const data = await readFile(absolutePath);
117
- return {
118
- type: "image",
119
- data: data.toString("base64"),
120
- mimeType: inferMimeType(absolutePath),
121
- };
122
- });
123
- }
124
-
125
- /**
126
- * @param {string} filePath
127
- * @returns {string}
128
- */
129
- function inferMimeType(filePath) {
130
- const extension = path.extname(filePath).toLowerCase();
131
- const mimeType = IMAGE_MIME_TYPES.get(extension);
132
- if (!mimeType) {
133
- throw new Error(
134
- `Unsupported image extension: ${extension} (file: ${filePath})`,
135
- );
136
- }
137
-
138
- return mimeType;
139
- }
140
-
141
- /**
142
- * @param {string} message
143
- * @returns {void}
144
- */
145
- function warn(message) {
146
- console.warn(styleText("yellow", message));
147
- }
@@ -1,210 +0,0 @@
1
- /**
2
- * @import { ProviderTokenUsage } from "./model"
3
- */
4
-
5
- /**
6
- * @typedef {Object} TokenBreakdown
7
- * @property {number} tokens - Token count
8
- * @property {number | undefined} cost - Cost (undefined if no pricing)
9
- */
10
-
11
- /**
12
- * @typedef {Object} CostSummary
13
- * @property {string} currency - Currency code (e.g., "USD")
14
- * @property {string} unit - Unit size (e.g., "1M")
15
- * @property {Record<string, TokenBreakdown>} breakdown - Token breakdown
16
- * @property {number | undefined} totalCost - Total cost (undefined if no pricing)
17
- */
18
-
19
- /**
20
- * @typedef {Object} CostConfig
21
- * @property {string} currency
22
- * @property {string} unit
23
- * @property {Record<string, number>} costs
24
- */
25
-
26
- /**
27
- * @typedef {Object} CostTracker
28
- * @property {(usage: ProviderTokenUsage) => void} recordUsage - Record token usage
29
- * @property {() => Record<string, number>} getAggregatedUsage - Get aggregated usage
30
- * @property {() => CostSummary} calculateCost - Calculate cost summary
31
- * @property {() => boolean} hasUsage - Check if any usage recorded
32
- */
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
-
64
- /**
65
- * Create a cost tracker for session token usage
66
- * @param {CostConfig} [costConfig] - Optional cost configuration
67
- * @returns {CostTracker}
68
- */
69
- export function createCostTracker(costConfig) {
70
- validateCostConfig(costConfig);
71
-
72
- /** @type {ProviderTokenUsage[]} */
73
- const usageHistory = [];
74
-
75
- /**
76
- * Record token usage from a provider.
77
- * Throws when usage is not a non-null object.
78
- * @param {ProviderTokenUsage} usage
79
- * @throws {TypeError} when usage is null, undefined, or not an object
80
- */
81
- function recordUsage(usage) {
82
- if (typeof usage !== "object" || usage === null) {
83
- throw new TypeError("usage must be a non-null object");
84
- }
85
- usageHistory.push(usage);
86
- }
87
-
88
- /**
89
- * Get aggregated token usage
90
- * @returns {Record<string, number>}
91
- */
92
- function getAggregatedUsage() {
93
- return aggregateTokens(usageHistory);
94
- }
95
-
96
- /**
97
- * Calculate cost summary
98
- * @returns {CostSummary}
99
- */
100
- function calculateCost() {
101
- const aggregated = aggregateTokens(usageHistory);
102
- return calculateCostFromConfig(aggregated, costConfig);
103
- }
104
-
105
- /**
106
- * Check if any usage recorded
107
- * @returns {boolean}
108
- */
109
- function hasUsage() {
110
- return usageHistory.length > 0;
111
- }
112
-
113
- return Object.freeze({
114
- recordUsage,
115
- getAggregatedUsage,
116
- calculateCost,
117
- hasUsage,
118
- });
119
- }
120
-
121
- /**
122
- * Aggregate token usage history by key
123
- * @param {ProviderTokenUsage[]} usageHistory
124
- * @returns {Record<string, number>}
125
- */
126
- function aggregateTokens(usageHistory) {
127
- /** @type {Record<string, number>} */
128
- const aggregated = {};
129
-
130
- for (const usage of usageHistory) {
131
- recursivelySumValues(usage, [], aggregated);
132
- }
133
-
134
- return aggregated;
135
- }
136
-
137
- /**
138
- * Recursively sum numeric values in token usage
139
- * @param {ProviderTokenUsage} obj
140
- * @param {string[]} path
141
- * @param {Record<string, number>} result
142
- */
143
- function recursivelySumValues(obj, path, result) {
144
- for (const [key, value] of Object.entries(obj)) {
145
- const currentPath = [...path, key];
146
- const pathStr = currentPath.join(".");
147
-
148
- if (typeof value === "number") {
149
- result[pathStr] = (result[pathStr] || 0) + value;
150
- } else if (
151
- typeof value === "object" &&
152
- value !== null &&
153
- !Array.isArray(value)
154
- ) {
155
- recursivelySumValues(value, currentPath, result);
156
- }
157
- }
158
- }
159
-
160
- /**
161
- * Calculate cost from aggregated tokens and config
162
- * @param {Record<string, number>} aggregated
163
- * @param {CostConfig | undefined} config
164
- * @returns {CostSummary}
165
- */
166
- function calculateCostFromConfig(aggregated, config) {
167
- /** @type {Record<string, TokenBreakdown>} */
168
- const breakdown = {};
169
- let totalCost = 0;
170
-
171
- for (const [key, tokens] of Object.entries(aggregated)) {
172
- breakdown[key] = Object.freeze({ tokens, cost: undefined });
173
-
174
- if (!config?.costs?.[key]) {
175
- continue;
176
- }
177
-
178
- const costValue = config.costs[key];
179
- const unitSize = parseUnit(config.unit);
180
-
181
- if (typeof costValue !== "number") {
182
- throw new TypeError(
183
- `config.costs["${key}"] must be a number, got ${typeof costValue}`,
184
- );
185
- }
186
-
187
- const cost = (tokens * costValue) / unitSize;
188
- breakdown[key] = Object.freeze({ tokens, cost });
189
- totalCost += cost;
190
- }
191
-
192
- return Object.freeze({
193
- currency: config?.currency ?? "USD",
194
- unit: config?.unit ?? "1M",
195
- breakdown,
196
- totalCost: config?.costs ? totalCost : undefined,
197
- });
198
- }
199
-
200
- /**
201
- * Parse unit string to number.
202
- * @param {string} unit
203
- * @returns {number}
204
- * @throws {Error} when the unit is not recognized
205
- */
206
- function parseUnit(unit) {
207
- if (unit === "1M") return 1_000_000;
208
- if (unit === "1K") return 1_000;
209
- throw new Error(`Unknown cost unit: "${unit}"`);
210
- }