@iinm/plain-agent
Advanced tools
| /** | ||
| * @import { ProviderTokenUsage } from "../model" | ||
| */ | ||
| /** | ||
| * @typedef {Object} TokenBreakdown | ||
| * @property {number} tokens - Token count | ||
| * @property {number | undefined} cost - Cost (undefined if no pricing) | ||
| */ | ||
| /** | ||
| * @typedef {Object} CostSummary | ||
| * @property {string} currency - Currency code (e.g., "USD") | ||
| * @property {string} unit - Unit size (e.g., "1M") | ||
| * @property {Record<string, TokenBreakdown>} breakdown - Token breakdown | ||
| * @property {number | undefined} totalCost - Total cost (undefined if no pricing) | ||
| */ | ||
| /** | ||
| * @typedef {Object} CostConfig | ||
| * @property {string} currency | ||
| * @property {string} unit | ||
| * @property {Record<string, number>} prices | ||
| */ | ||
| /** | ||
| * @typedef {Object} CostTracker | ||
| * @property {(usage: ProviderTokenUsage) => void} recordUsage - Record token usage | ||
| * @property {() => Record<string, number>} getAggregatedUsage - Get aggregated usage | ||
| * @property {() => CostSummary} calculateCost - Calculate cost summary | ||
| * @property {() => boolean} hasUsage - Check if any usage recorded | ||
| * @property {() => ProviderTokenUsage[]} getUsageHistory - Get a snapshot of the raw usage history | ||
| * @property {(history: ProviderTokenUsage[]) => void} restoreUsageHistory - Replace the usage history (used when resuming a saved session) | ||
| */ | ||
| /** | ||
| * Validate a cost configuration object at runtime. | ||
| * @param {unknown} config | ||
| */ | ||
| function validateCostConfig(config) { | ||
| if (config === undefined) return; | ||
| if (typeof config !== "object" || config === null) { | ||
| throw new TypeError("CostConfig must be an object"); | ||
| } | ||
| const c = /** @type {Record<string, unknown>} */ (config); | ||
| if (typeof c.currency !== "string") { | ||
| throw new TypeError("CostConfig.currency must be a string"); | ||
| } | ||
| if (typeof c.unit !== "string") { | ||
| throw new TypeError("CostConfig.unit must be a string"); | ||
| } | ||
| if (typeof c.prices !== "object" || c.prices === null) { | ||
| throw new TypeError("CostConfig.prices must be an object"); | ||
| } | ||
| for (const [key, value] of Object.entries( | ||
| /** @type {Record<string, unknown>} */ (c.prices), | ||
| )) { | ||
| if (typeof value !== "number") { | ||
| throw new TypeError( | ||
| `CostConfig.prices["${key}"] must be a number, got ${typeof value}`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Create a cost tracker for session token usage | ||
| * @param {CostConfig} [costConfig] - Optional cost configuration | ||
| * @returns {CostTracker} | ||
| */ | ||
| export function createCostTracker(costConfig) { | ||
| validateCostConfig(costConfig); | ||
| /** @type {ProviderTokenUsage[]} */ | ||
| const usageHistory = []; | ||
| /** | ||
| * Record token usage from a provider. | ||
| * Throws when usage is not a non-null object. | ||
| * @param {ProviderTokenUsage} usage | ||
| * @throws {TypeError} when usage is null, undefined, or not an object | ||
| */ | ||
| function recordUsage(usage) { | ||
| if (typeof usage !== "object" || usage === null) { | ||
| throw new TypeError("usage must be a non-null object"); | ||
| } | ||
| usageHistory.push(usage); | ||
| } | ||
| /** | ||
| * Get aggregated token usage | ||
| * @returns {Record<string, number>} | ||
| */ | ||
| function getAggregatedUsage() { | ||
| return aggregateTokens(usageHistory); | ||
| } | ||
| /** | ||
| * Calculate cost summary | ||
| * @returns {CostSummary} | ||
| */ | ||
| function calculateCost() { | ||
| const aggregated = aggregateTokens(usageHistory); | ||
| return calculateCostFromConfig(aggregated, costConfig); | ||
| } | ||
| /** | ||
| * Check if any usage recorded | ||
| * @returns {boolean} | ||
| */ | ||
| function hasUsage() { | ||
| return usageHistory.length > 0; | ||
| } | ||
| /** | ||
| * Get a snapshot copy of the raw usage history. | ||
| * @returns {ProviderTokenUsage[]} | ||
| */ | ||
| function getUsageHistory() { | ||
| return usageHistory.map((u) => u); | ||
| } | ||
| /** | ||
| * Replace the usage history. Used when resuming a saved session. | ||
| * @param {ProviderTokenUsage[]} history | ||
| */ | ||
| function restoreUsageHistory(history) { | ||
| if (!Array.isArray(history)) { | ||
| throw new TypeError("history must be an array"); | ||
| } | ||
| usageHistory.length = 0; | ||
| for (const usage of history) { | ||
| if (typeof usage !== "object" || usage === null) { | ||
| throw new TypeError("each usage entry must be a non-null object"); | ||
| } | ||
| usageHistory.push(usage); | ||
| } | ||
| } | ||
| return Object.freeze({ | ||
| recordUsage, | ||
| getAggregatedUsage, | ||
| calculateCost, | ||
| hasUsage, | ||
| getUsageHistory, | ||
| restoreUsageHistory, | ||
| }); | ||
| } | ||
| /** | ||
| * Aggregate token usage history by key | ||
| * @param {ProviderTokenUsage[]} usageHistory | ||
| * @returns {Record<string, number>} | ||
| */ | ||
| function aggregateTokens(usageHistory) { | ||
| /** @type {Record<string, number>} */ | ||
| const aggregated = {}; | ||
| for (const usage of usageHistory) { | ||
| recursivelySumValues(usage, [], aggregated); | ||
| } | ||
| return aggregated; | ||
| } | ||
| /** | ||
| * Recursively sum numeric values in token usage | ||
| * @param {ProviderTokenUsage} obj | ||
| * @param {string[]} path | ||
| * @param {Record<string, number>} result | ||
| */ | ||
| function recursivelySumValues(obj, path, result) { | ||
| for (const [key, value] of Object.entries(obj)) { | ||
| const currentPath = [...path, key]; | ||
| const pathStr = currentPath.join("."); | ||
| if (typeof value === "number") { | ||
| result[pathStr] = (result[pathStr] || 0) + value; | ||
| } else if ( | ||
| typeof value === "object" && | ||
| value !== null && | ||
| !Array.isArray(value) | ||
| ) { | ||
| recursivelySumValues(value, currentPath, result); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Calculate cost from aggregated tokens and config | ||
| * @param {Record<string, number>} aggregated | ||
| * @param {CostConfig | undefined} config | ||
| * @returns {CostSummary} | ||
| */ | ||
| function calculateCostFromConfig(aggregated, config) { | ||
| /** @type {Record<string, TokenBreakdown>} */ | ||
| const breakdown = {}; | ||
| let totalCost = 0; | ||
| for (const [key, tokens] of Object.entries(aggregated)) { | ||
| breakdown[key] = Object.freeze({ tokens, cost: undefined }); | ||
| if (!config?.prices?.[key]) { | ||
| continue; | ||
| } | ||
| const costValue = config.prices[key]; | ||
| const unitSize = parseUnit(config.unit); | ||
| if (typeof costValue !== "number") { | ||
| throw new TypeError( | ||
| `config.prices["${key}"] must be a number, got ${typeof costValue}`, | ||
| ); | ||
| } | ||
| const cost = (tokens * costValue) / unitSize; | ||
| breakdown[key] = Object.freeze({ tokens, cost }); | ||
| totalCost += cost; | ||
| } | ||
| return Object.freeze({ | ||
| currency: config?.currency ?? "USD", | ||
| unit: config?.unit ?? "1M", | ||
| totalCost: config?.prices ? totalCost : undefined, | ||
| breakdown, | ||
| }); | ||
| } | ||
| /** | ||
| * Parse unit string to number. | ||
| * @param {string} unit | ||
| * @returns {number} | ||
| * @throws {Error} when the unit is not recognized | ||
| */ | ||
| function parseUnit(unit) { | ||
| if (unit === "1M") return 1_000_000; | ||
| if (unit === "1K") return 1_000; | ||
| throw new Error(`Unknown cost unit: "${unit}"`); | ||
| } |
| /** | ||
| * @import { CostSummary } from "./costTracker.mjs" | ||
| */ | ||
| import fs from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { USAGE_LOG_PATH } from "../env.mjs"; | ||
| /** | ||
| * @typedef {Object} UsageRecord | ||
| * @property {string} timestamp - ISO 8601 timestamp | ||
| * @property {string} sessionId | ||
| * @property {"interactive" | "batch"} mode | ||
| * @property {string} modelName - e.g. "claude-sonnet-4-6+thinking-high" | ||
| * @property {string} workingDir | ||
| * @property {string} currency - e.g. "USD" | ||
| * @property {string} unit - e.g. "1M" | ||
| * @property {number | null} totalCost - null when no pricing configured | ||
| * @property {Record<string, number>} tokens - aggregated token counts by path | ||
| */ | ||
| /** | ||
| * Maximum size (in bytes) of a single JSONL line. | ||
| * Linux guarantees atomicity of O_APPEND writes up to PIPE_BUF (4096 bytes), | ||
| * so we enforce a smaller limit to stay safely under that threshold even | ||
| * with multi-byte UTF-8 characters in model/session names. | ||
| */ | ||
| const MAX_RECORD_BYTES = 3072; | ||
| /** | ||
| * Append a usage record to the persistent usage log. | ||
| * | ||
| * On POSIX systems, `fs.appendFile` opens the file with `O_APPEND`, which | ||
| * guarantees that each write lands at end-of-file and is atomic when the | ||
| * payload is <= PIPE_BUF (4096 bytes on Linux). We write the record as a | ||
| * single call to preserve this guarantee even if multiple plain-agent | ||
| * sessions finish simultaneously. | ||
| * | ||
| * @param {UsageRecord} record | ||
| * @param {{ path?: string }} [options] | ||
| * @returns {Promise<void | Error>} | ||
| */ | ||
| export async function appendUsageRecord(record, options = {}) { | ||
| const filePath = options.path ?? USAGE_LOG_PATH; | ||
| const line = `${JSON.stringify(record)}\n`; | ||
| const bytes = Buffer.byteLength(line, "utf8"); | ||
| if (bytes > MAX_RECORD_BYTES) { | ||
| return new Error( | ||
| `Usage record exceeds ${MAX_RECORD_BYTES} bytes (${bytes}); refusing to write to keep appends atomic.`, | ||
| ); | ||
| } | ||
| try { | ||
| await fs.mkdir(path.dirname(filePath), { recursive: true }); | ||
| await fs.appendFile(filePath, line, { encoding: "utf8" }); | ||
| } catch (err) { | ||
| if (err instanceof Error) return err; | ||
| } | ||
| } | ||
| /** | ||
| * Read all usage records from the log file. | ||
| * Malformed lines are skipped and collected in `skipped`. | ||
| * | ||
| * @param {{ path?: string }} [options] | ||
| * @returns {Promise<{ records: UsageRecord[], skipped: { line: number, reason: string }[] }>} | ||
| */ | ||
| export async function readUsageRecords(options = {}) { | ||
| const filePath = options.path ?? USAGE_LOG_PATH; | ||
| /** @type {string} */ | ||
| let content; | ||
| try { | ||
| content = await fs.readFile(filePath, "utf8"); | ||
| } catch (err) { | ||
| if ( | ||
| err instanceof Error && | ||
| /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT" | ||
| ) { | ||
| return { records: [], skipped: [] }; | ||
| } | ||
| throw err; | ||
| } | ||
| /** @type {UsageRecord[]} */ | ||
| const records = []; | ||
| /** @type {{ line: number, reason: string }[]} */ | ||
| const skipped = []; | ||
| const lines = content.split("\n"); | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const raw = lines[i]; | ||
| if (raw.length === 0) continue; | ||
| try { | ||
| const parsed = JSON.parse(raw); | ||
| if (!isUsageRecord(parsed)) { | ||
| skipped.push({ line: i + 1, reason: "invalid shape" }); | ||
| continue; | ||
| } | ||
| records.push(parsed); | ||
| } catch (err) { | ||
| const reason = err instanceof Error ? err.message : String(err); | ||
| skipped.push({ line: i + 1, reason }); | ||
| } | ||
| } | ||
| return { records, skipped }; | ||
| } | ||
| /** | ||
| * Build a usage record from a finished session's cost summary. | ||
| * Returns null when there's nothing worth recording (no tokens). | ||
| * | ||
| * @param {Object} args | ||
| * @param {string} args.sessionId | ||
| * @param {"interactive" | "batch"} args.mode | ||
| * @param {string} args.modelName | ||
| * @param {string} args.workingDir | ||
| * @param {CostSummary} args.costSummary | ||
| * @param {Date} [args.now] | ||
| * @returns {UsageRecord | null} | ||
| */ | ||
| export function buildUsageRecord({ | ||
| sessionId, | ||
| mode, | ||
| modelName, | ||
| workingDir, | ||
| costSummary, | ||
| now, | ||
| }) { | ||
| /** @type {Record<string, number>} */ | ||
| const tokens = {}; | ||
| for (const [key, entry] of Object.entries(costSummary.breakdown)) { | ||
| tokens[key] = entry.tokens; | ||
| } | ||
| if (Object.keys(tokens).length === 0) { | ||
| return null; | ||
| } | ||
| const timestamp = (now ?? new Date()).toISOString(); | ||
| return { | ||
| timestamp, | ||
| sessionId, | ||
| mode, | ||
| modelName, | ||
| workingDir, | ||
| currency: costSummary.currency, | ||
| unit: costSummary.unit, | ||
| totalCost: | ||
| costSummary.totalCost === undefined ? null : costSummary.totalCost, | ||
| tokens, | ||
| }; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {value is UsageRecord} | ||
| */ | ||
| function isUsageRecord(value) { | ||
| if (typeof value !== "object" || value === null) return false; | ||
| const r = /** @type {Record<string, unknown>} */ (value); | ||
| return ( | ||
| typeof r.timestamp === "string" && | ||
| typeof r.sessionId === "string" && | ||
| (r.mode === "interactive" || r.mode === "batch") && | ||
| typeof r.modelName === "string" && | ||
| typeof r.workingDir === "string" && | ||
| typeof r.currency === "string" && | ||
| typeof r.unit === "string" && | ||
| (r.totalCost === null || typeof r.totalCost === "number") && | ||
| typeof r.tokens === "object" && | ||
| r.tokens !== null | ||
| ); | ||
| } |
| import { AnthropicModelConfig } from "./providers/anthropic"; | ||
| import { BedrockConverseModelConfig } from "./providers/bedrock"; | ||
| import { GeminiModelConfig } from "./providers/gemini"; | ||
| import { OpenAIModelConfig } from "./providers/openai"; | ||
| import { OpenAICompatibleModelConfig } from "./providers/openaiCompatible"; | ||
| export type ModelDefinition = { | ||
| name: string; | ||
| variant: string; | ||
| platform: PlatformConfig; | ||
| model: ModelConfig; | ||
| cost?: CostConfig; | ||
| autoCompact?: ModelAutoCompactConfig; | ||
| }; | ||
| export type ModelAutoCompactConfig = { | ||
| /** Keys in providerTokenUsage whose values are summed to get the input token count. */ | ||
| inputTokensKeys: string[]; | ||
| }; | ||
| export type PlatformConfig = | ||
| | { | ||
| name: "anthropic"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| apiKey: string; | ||
| } | ||
| | { | ||
| name: "gemini"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| apiKey: string; | ||
| } | ||
| | { | ||
| name: "openai"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| apiKey: string; | ||
| } | ||
| | { | ||
| name: "openai-compatible"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| apiKey: string; | ||
| } | ||
| | { | ||
| name: "azure"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| azureConfigDir?: string; | ||
| } | ||
| | { | ||
| name: "bedrock"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| awsProfile: string; | ||
| } | ||
| | { | ||
| name: "vertex-ai"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| account?: string; | ||
| }; | ||
| export type ModelConfig = | ||
| | { | ||
| format: "anthropic"; | ||
| config: AnthropicModelConfig; | ||
| } | ||
| | { | ||
| format: "gemini"; | ||
| config: GeminiModelConfig; | ||
| } | ||
| | { | ||
| format: "openai-responses"; | ||
| config: OpenAIModelConfig; | ||
| } | ||
| | { | ||
| format: "openai-messages"; | ||
| config: OpenAICompatibleModelConfig; | ||
| } | ||
| | { | ||
| format: "bedrock-converse"; | ||
| config: BedrockConverseModelConfig; | ||
| }; | ||
| export type CostConfig = { | ||
| currency: string; | ||
| unit: string; | ||
| prices: Record<string, number>; | ||
| }; |
| import { callAnthropicModel } from "./providers/anthropic.mjs"; | ||
| import { callBedrockConverseModel } from "./providers/bedrock.mjs"; | ||
| import { createCacheEnabledGeminiModelCaller } from "./providers/gemini.mjs"; | ||
| import { callOpenAIModel } from "./providers/openai.mjs"; | ||
| import { callOpenAICompatibleModel } from "./providers/openaiCompatible.mjs"; | ||
| /** | ||
| * @param {import("./model.definition").ModelDefinition} modelDef | ||
| * @returns {import("./model").CallModel} | ||
| */ | ||
| export function createModelCaller(modelDef) { | ||
| const { platform, model } = modelDef; | ||
| switch (model.format) { | ||
| case "anthropic": | ||
| return (input) => callAnthropicModel(platform, model.config, input); | ||
| case "gemini": { | ||
| const modelCaller = createCacheEnabledGeminiModelCaller( | ||
| platform, | ||
| model.config, | ||
| ); | ||
| return (input) => modelCaller(model.config, input); | ||
| } | ||
| case "openai-responses": | ||
| return (input) => callOpenAIModel(platform, model.config, input); | ||
| case "openai-messages": | ||
| return (input) => | ||
| callOpenAICompatibleModel(platform, model.config, input); | ||
| case "bedrock-converse": | ||
| return (input) => callBedrockConverseModel(platform, model.config, input); | ||
| } | ||
| } |
+1
-1
| { | ||
| "name": "@iinm/plain-agent", | ||
| "version": "1.15.3", | ||
| "version": "1.15.4", | ||
| "description": "A lightweight terminal-based coding agent focused on safety and low token cost", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+1
-7
| # Plain Agent | ||
| [](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql) | ||
| [](https://socket.dev/npm/package/@iinm/plain-agent) | ||
| [](https://socket.dev/npm/package/@iinm/plain-agent) | ||
| [](https://packagephobia.com/result?p=@iinm/plain-agent) | ||
@@ -376,8 +376,2 @@ | ||
| "apiKey": "<FIREWORKS_API_KEY>" | ||
| }, | ||
| { | ||
| "name": "openai-compatible", | ||
| "variant": "novita", | ||
| "baseURL": "https://api.novita.ai/openai", | ||
| "apiKey": "<NOVITA_API_KEY>" | ||
| } | ||
@@ -384,0 +378,0 @@ ] |
+5
-5
| import type { AgentRole } from "./context/loadAgentRoles.mjs"; | ||
| import type { CostConfig, CostSummary } from "./costTracker.mjs"; | ||
| import type { CostSummary } from "./metrics/costTracker.mjs"; | ||
| import type { | ||
@@ -29,3 +29,3 @@ CallModel, | ||
| send: (input: AgentInput) => void; | ||
| getCostSummary: () => CostSummary; | ||
| stop: () => void; | ||
| pauseAutoApprove: () => void; | ||
@@ -40,3 +40,3 @@ /** Subagent currently active for this session, or null. */ | ||
| */ | ||
| export type AgentEvent = | ||
| export type AgentEvent = { timestamp: Date } & ( | ||
| | { | ||
@@ -65,3 +65,4 @@ type: "session_start"; | ||
| } | ||
| | { type: "messages_reset"; messages: Message[] }; | ||
| | { type: "messages_reset"; messages: Message[] } | ||
| ); | ||
@@ -77,3 +78,2 @@ /** Sink used by the agent loop to push events onto the output stream. */ | ||
| agentRoles: Map<string, AgentRole>; | ||
| modelCostConfig?: CostConfig; | ||
| /** Metadata used when persisting session state. */ | ||
@@ -80,0 +80,0 @@ sessionMetadata: { |
+32
-47
| /** | ||
| * @import { Agent, AgentConfig, AgentEvent, AgentInput } from "./agent" | ||
| * @import { PauseSignal } from "./agentLoop.mjs"; | ||
| * @import { SystemMessage } from "./model" | ||
| * @import { Tool, ToolDefinition } from "./tool" | ||
@@ -12,3 +14,2 @@ * @import { CompactContextInput } from "./tools/compactContext" | ||
| import { createStateManager } from "./agentState.mjs"; | ||
| import { createCostTracker } from "./costTracker.mjs"; | ||
| import { SESSION_FORMAT_VERSION } from "./sessionStore.mjs"; | ||
@@ -35,3 +36,2 @@ import { createSubagentManager } from "./subagent.mjs"; | ||
| agentRoles, | ||
| modelCostConfig, | ||
| sessionMetadata, | ||
@@ -42,32 +42,8 @@ initialState, | ||
| }) { | ||
| /** | ||
| * Pull-based input queue. CLI callers push input via send(); the | ||
| * agent's run loop consumes it as an async iterable. | ||
| * @type {AsyncQueue<AgentInput>} | ||
| */ | ||
| /** @type {AsyncQueue<AgentInput>} */ | ||
| const inputQueue = createAsyncQueue(); | ||
| /** | ||
| * Output stream of agent events, yielded by run(). | ||
| * @type {AsyncQueue<AgentEvent>} | ||
| */ | ||
| /** @type {AsyncQueue<AgentEvent>} */ | ||
| const eventQueue = createAsyncQueue(); | ||
| const costTracker = createCostTracker(modelCostConfig); | ||
| /** | ||
| * Emit an agent event onto the output stream. token_usage events are | ||
| * also recorded by the cost tracker as they pass through. | ||
| * @param {AgentEvent} event | ||
| */ | ||
| const emitEvent = (event) => { | ||
| if (event.type === "token_usage") { | ||
| costTracker.recordUsage(event.usage); | ||
| } | ||
| eventQueue.push(event); | ||
| }; | ||
| // Build the initial message list. When resuming, replace messages[0] with | ||
| // the freshly built system prompt (today/agent roles/skills may have | ||
| // changed) but keep the rest of the saved conversation verbatim. | ||
| /** @type {import("./model").SystemMessage} */ | ||
| /** @type {SystemMessage} */ | ||
| const systemMessage = { | ||
@@ -84,7 +60,11 @@ role: "system", | ||
| for (const message of newMessages) { | ||
| emitEvent({ type: "message", message }); | ||
| eventQueue.push({ timestamp: new Date(), type: "message", message }); | ||
| } | ||
| }, | ||
| onMessagesReplaced: (messages) => | ||
| emitEvent({ type: "messages_reset", messages }), | ||
| eventQueue.push({ | ||
| timestamp: new Date(), | ||
| type: "messages_reset", | ||
| messages, | ||
| }), | ||
| }); | ||
@@ -94,12 +74,12 @@ | ||
| onSubagentSwitched: (subagent) => { | ||
| emitEvent({ type: "subagent_switched", subagent }); | ||
| eventQueue.push({ | ||
| timestamp: new Date(), | ||
| type: "subagent_switched", | ||
| subagent, | ||
| }); | ||
| }, | ||
| }); | ||
| // Restore the rest of the session state. Subagent restoration is silent | ||
| // (no event), since CLI listeners aren't attached yet — the CLI consults | ||
| // getActiveSubagent() at startup instead. | ||
| if (initialState) { | ||
| subagentManager.restoreState(initialState.subagentState); | ||
| costTracker.restoreUsageHistory(initialState.tokenUsageHistory); | ||
| } | ||
@@ -170,3 +150,3 @@ /** | ||
| let paused = false; | ||
| /** @type {import("./agentLoop.mjs").PauseSignal} */ | ||
| /** @type {PauseSignal} */ | ||
| const pauseSignal = { | ||
@@ -184,3 +164,3 @@ isPaused: () => paused, | ||
| toolExecutor, | ||
| emitEvent, | ||
| emitEvent: (event) => eventQueue.push(event), | ||
| toolUseApprover, | ||
@@ -193,5 +173,2 @@ subagentManager, | ||
| // Drive the agent by consuming user input from the pull-based queue and | ||
| // running one turn loop per input. Runs for the lifetime of the process. | ||
| let inputLoopStarted = false; | ||
| let sessionStartEmitted = false; | ||
@@ -201,3 +178,4 @@ const emitSessionStartOnce = () => { | ||
| sessionStartEmitted = true; | ||
| emitEvent({ | ||
| eventQueue.push({ | ||
| timestamp: new Date(), | ||
| type: "session_start", | ||
@@ -210,3 +188,4 @@ sessionFormatVersion: SESSION_FORMAT_VERSION, | ||
| }); | ||
| emitEvent({ | ||
| eventQueue.push({ | ||
| timestamp: new Date(), | ||
| type: "messages_reset", | ||
@@ -216,3 +195,5 @@ messages: stateManager.getMessages(), | ||
| }; | ||
| const startInputLoop = () => { | ||
| let inputLoopStarted = false; | ||
| const startInputLoopOnce = () => { | ||
| if (inputLoopStarted) return; | ||
@@ -225,3 +206,4 @@ inputLoopStarted = true; | ||
| })().catch((err) => { | ||
| emitEvent({ | ||
| eventQueue.push({ | ||
| timestamp: new Date(), | ||
| type: "error", | ||
@@ -235,3 +217,3 @@ error: err instanceof Error ? err : new Error(String(err)), | ||
| start() { | ||
| startInputLoop(); | ||
| startInputLoopOnce(); | ||
| return eventQueue; | ||
@@ -243,3 +225,6 @@ }, | ||
| }, | ||
| getCostSummary: () => costTracker.calculateCost(), | ||
| stop() { | ||
| inputQueue.close(); | ||
| eventQueue.close(); | ||
| }, | ||
| pauseAutoApprove: () => { | ||
@@ -246,0 +231,0 @@ paused = true; |
+76
-41
| /** | ||
| * @import { AgentEventSink } from "./agent" | ||
| * @import { CallModel, MessageContentText, MessageContentImage, MessageContentToolResult, PartialMessageContent, UserMessage, MessageContentToolUse } from "./model" | ||
| * @import { StateManager } from "./agentState.mjs" | ||
| * @import { CallModel, MessageContentText, MessageContentImage, MessageContentToolResult, PartialMessageContent, UserMessage, MessageContentToolUse, ProviderTokenUsage } from "./model" | ||
| * @import { ToolDefinition, ToolUseApprover } from "./tool" | ||
| * @import { ToolExecutor } from "./toolExecutor.mjs"; | ||
| * @import { SubagentManager } from "./subagent.mjs" | ||
| * @import { StateManager } from "./agentState.mjs" | ||
| */ | ||
@@ -11,36 +12,5 @@ | ||
| import { buildCompactPrompt } from "./prompt.mjs"; | ||
| import { extractInputTokenCount } from "./tokenUsage.mjs"; | ||
| import { compactContextToolName } from "./tools/compactContext.mjs"; | ||
| /** | ||
| * If compact_context was called successfully, discard the prior conversation | ||
| * (keeping only the system prompt) and append the tool result as a standard | ||
| * user message so the model can resume from the reloaded memory file. | ||
| * @param {StateManager} stateManager | ||
| * @param {MessageContentToolUse[]} toolUseParts | ||
| * @param {MessageContentToolResult[]} toolResults | ||
| * @returns {boolean} true if compact was applied | ||
| */ | ||
| function applyCompactContextIfCalled(stateManager, toolUseParts, toolResults) { | ||
| const compactToolUse = toolUseParts.find( | ||
| (t) => t.toolName === compactContextToolName, | ||
| ); | ||
| if (!compactToolUse) return false; | ||
| const compactResult = toolResults.find( | ||
| (r) => r.toolUseId === compactToolUse.toolUseId, | ||
| ); | ||
| if (!compactResult || compactResult.isError) return false; | ||
| const systemMessage = stateManager.getMessageAt(0); | ||
| if (!systemMessage) return false; | ||
| stateManager.replaceMessages([systemMessage]); | ||
| stateManager.appendMessages([ | ||
| { role: "user", content: compactResult.content }, | ||
| ]); | ||
| return true; | ||
| } | ||
| /** | ||
| * @typedef {Object} PauseSignal | ||
@@ -56,3 +26,3 @@ * @property {() => boolean} isPaused - Returns true if auto-approve should be paused | ||
| * @property {ToolDefinition[]} toolDefs - Tool definitions for the model | ||
| * @property {import("./toolExecutor.mjs").ToolExecutor} toolExecutor - Tool executor instance | ||
| * @property {ToolExecutor} toolExecutor - Tool executor instance | ||
| * @property {AgentEventSink} emitEvent - Sink that pushes agent events onto the output stream | ||
@@ -103,3 +73,3 @@ * @property {ToolUseApprover} toolUseApprover - Tool use approval checker | ||
| await runTurnLoop(); | ||
| emitEvent({ type: "turn_end" }); | ||
| emitEvent({ timestamp: new Date(), type: "turn_end" }); | ||
| } | ||
@@ -139,3 +109,7 @@ | ||
| onPartialMessageContent: (partialContent) => { | ||
| emitEvent({ type: "partial_message_content", partialContent }); | ||
| emitEvent({ | ||
| timestamp: new Date(), | ||
| type: "partial_message_content", | ||
| partialContent, | ||
| }); | ||
| }, | ||
@@ -145,3 +119,3 @@ }); | ||
| if (modelOutput instanceof Error) { | ||
| emitEvent({ type: "error", error: modelOutput }); | ||
| emitEvent({ timestamp: new Date(), type: "error", error: modelOutput }); | ||
| break; | ||
@@ -153,3 +127,7 @@ } | ||
| if (providerTokenUsage) { | ||
| emitEvent({ type: "token_usage", usage: providerTokenUsage }); | ||
| emitEvent({ | ||
| timestamp: new Date(), | ||
| type: "token_usage", | ||
| usage: providerTokenUsage, | ||
| }); | ||
| } | ||
@@ -228,2 +206,3 @@ | ||
| emitEvent({ | ||
| timestamp: new Date(), | ||
| type: "tool_use_request", | ||
@@ -239,2 +218,3 @@ toolUseCount: toolUseParts.length, | ||
| emitEvent({ | ||
| timestamp: new Date(), | ||
| type: "tool_use_request", | ||
@@ -360,5 +340,5 @@ toolUseCount: toolUseParts.length, | ||
| * @property {StateManager} stateManager | ||
| * @property {import("./toolExecutor.mjs").ToolExecutor} toolExecutor | ||
| * @property {import("./subagent.mjs").SubagentManager} subagentManager | ||
| * @property {import("./tool.d.ts").ToolUseApprover} toolUseApprover | ||
| * @property {ToolExecutor} toolExecutor | ||
| * @property {SubagentManager} subagentManager | ||
| * @property {ToolUseApprover} toolUseApprover | ||
| */ | ||
@@ -519,1 +499,56 @@ | ||
| } | ||
| /** | ||
| * If compact_context was called successfully, discard the prior conversation | ||
| * (keeping only the system prompt) and append the tool result as a standard | ||
| * user message so the model can resume from the reloaded memory file. | ||
| * @param {StateManager} stateManager | ||
| * @param {MessageContentToolUse[]} toolUseParts | ||
| * @param {MessageContentToolResult[]} toolResults | ||
| * @returns {boolean} true if compact was applied | ||
| */ | ||
| function applyCompactContextIfCalled(stateManager, toolUseParts, toolResults) { | ||
| const compactToolUse = toolUseParts.find( | ||
| (t) => t.toolName === compactContextToolName, | ||
| ); | ||
| if (!compactToolUse) return false; | ||
| const compactResult = toolResults.find( | ||
| (r) => r.toolUseId === compactToolUse.toolUseId, | ||
| ); | ||
| if (!compactResult || compactResult.isError) return false; | ||
| const systemMessage = stateManager.getMessageAt(0); | ||
| if (!systemMessage) return false; | ||
| stateManager.replaceMessages([systemMessage]); | ||
| stateManager.appendMessages([ | ||
| { role: "user", content: compactResult.content }, | ||
| ]); | ||
| return true; | ||
| } | ||
| /** | ||
| * Extract the input (prompt/context) token count from a single turn's | ||
| * provider token usage object by summing the values of the specified keys. | ||
| * | ||
| * Returns `undefined` when no specified key yields a positive number. | ||
| * | ||
| * @param {ProviderTokenUsage} usage | ||
| * @param {string[]} inputTokensKeys - Keys whose numeric values are summed. | ||
| * @returns {number | undefined} | ||
| */ | ||
| export function extractInputTokenCount(usage, inputTokensKeys) { | ||
| let total = 0; | ||
| let found = false; | ||
| for (const key of inputTokensKeys) { | ||
| const value = usage[key]; | ||
| if (typeof value === "number" && value > 0) { | ||
| total += value; | ||
| found = true; | ||
| } | ||
| } | ||
| return found ? total : undefined; | ||
| } |
+8
-7
@@ -242,10 +242,11 @@ /** | ||
| plain resume [<sessionId>] [--list] | ||
| plain sandbox [-c <file>...] -- [sandbox args...] | ||
| plain cost [--from YYYY-MM-DD] [--to YYYY-MM-DD] | ||
| plain list-models | ||
| plain install-claude-code-plugins | ||
| plain sandbox [-c <file>...] -- [sandbox args...] | ||
| plain test-approval [options] | ||
| Options: | ||
| -h, --help Show this help message | ||
| -m, --model <model+variant> Model to use | ||
| -h, --help Show this help message | ||
| -c, --config <file> Config file to load (repeatable) | ||
@@ -262,6 +263,2 @@ | ||
| not supported (-m is rejected). | ||
| test-approval Run auto-approval rule tests defined in config. | ||
| sandbox Launch the sandbox command using the app | ||
| config's sandbox settings. Arguments after -- | ||
| are passed through to the sandbox command as-is. | ||
| cost Show aggregated token cost per day for a period. | ||
@@ -272,5 +269,9 @@ Defaults to the first day of the current month | ||
| install-claude-code-plugins Install Claude Code plugins | ||
| sandbox Launch the sandbox command using the app | ||
| config's sandbox settings. Arguments after -- | ||
| are passed through to the sandbox command as-is. | ||
| test-approval Run auto-approval rule tests defined in config. | ||
| Examples: | ||
| plain -m claude-sonnet-4-6+thinking-high | ||
| plain -m claude-sonnet-5+thinking-high | ||
| plain batch \\ | ||
@@ -277,0 +278,0 @@ -c ~/.config/plain-agent/config.local.json \\ |
+57
-80
| /** | ||
| * @import { Agent } from "../agent" | ||
| * @import { Agent, AgentEvent } from "../agent" | ||
| * @import { CostTracker } from "../metrics/costTracker.mjs"; | ||
| */ | ||
| import { persistSessionEvent } from "../sessionStore.mjs"; | ||
| import { appendUsageRecord, buildUsageRecord } from "../usageStore.mjs"; | ||
| import { formatCostForBatch } from "./formatter.mjs"; | ||
| import { appendUsageRecord, buildUsageRecord } from "../metrics/usageStore.mjs"; | ||
| import { | ||
| PERSISTED_SESSION_EVENT_TYPES, | ||
| persistSessionEvent, | ||
| } from "../sessionStore.mjs"; | ||
@@ -15,4 +18,4 @@ /** | ||
| * @property {string} modelName | ||
| * @property {boolean} sandbox | ||
| * @property {Date} startTime | ||
| * @property {CostTracker} costTracker | ||
| * @property {() => Promise<void>} onStop | ||
@@ -33,96 +36,70 @@ */ | ||
| modelName, | ||
| sandbox, | ||
| startTime, | ||
| costTracker, | ||
| onStop, | ||
| }) { | ||
| outputEvent({ | ||
| type: "session_start", | ||
| sessionId, | ||
| modelName, | ||
| sandbox, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| agent.send([{ type: "text", text: task }]); | ||
| // The first turn_end marks completion of the batch task and ends the session. | ||
| for await (const event of agent.start()) { | ||
| await persistSessionEvent(sessionId, event); | ||
| switch (event.type) { | ||
| case "message": | ||
| outputEvent({ | ||
| type: "message", | ||
| message: event.message, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| break; | ||
| case "error": | ||
| outputEvent({ | ||
| type: "error", | ||
| error: { message: event.error.message, stack: event.error.stack }, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| process.exit(1); | ||
| break; | ||
| if (PERSISTED_SESSION_EVENT_TYPES.has(event.type)) { | ||
| outputEvent(event); | ||
| } else if (event.type === "error") { | ||
| outputEvent(event); | ||
| } | ||
| case "subagent_switched": | ||
| outputEvent({ | ||
| type: "subagent_switched", | ||
| subagent: event.subagent, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| break; | ||
| if (["error", "turn_end"].includes(event.type)) { | ||
| agent.stop(); | ||
| } | ||
| } | ||
| case "token_usage": | ||
| outputEvent({ | ||
| type: "token_usage", | ||
| usage: event.usage, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| break; | ||
| const costSummary = costTracker.calculateCost(); | ||
| case "turn_end": { | ||
| const costSummary = agent.getCostSummary(); | ||
| const sessionEnd = { type: "session_end", cost: costSummary }; | ||
| await persistSessionEvent(sessionId, sessionEnd); | ||
| outputEvent({ | ||
| type: "session_end", | ||
| timestamp: new Date().toISOString(), | ||
| cost: formatCostForBatch(costSummary), | ||
| }); | ||
| const record = buildUsageRecord({ | ||
| sessionId, | ||
| mode: "batch", | ||
| modelName, | ||
| workingDir: process.cwd(), | ||
| costSummary, | ||
| now: startTime, | ||
| }); | ||
| try { | ||
| const record = buildUsageRecord({ | ||
| sessionId, | ||
| mode: "batch", | ||
| modelName, | ||
| workingDir: process.cwd(), | ||
| costSummary, | ||
| now: startTime, | ||
| }); | ||
| if (record) await appendUsageRecord(record); | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| outputEvent({ | ||
| type: "error", | ||
| error: { message: `failed to record usage: ${message}` }, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| } | ||
| await onStop(); | ||
| process.exit(0); | ||
| } | ||
| if (record) { | ||
| const recordError = await appendUsageRecord(record); | ||
| if (recordError instanceof Error) { | ||
| outputEvent({ | ||
| type: "error", | ||
| error: recordError, | ||
| timestamp: new Date(), | ||
| }); | ||
| } | ||
| } | ||
| /** @type {AgentEvent} */ | ||
| const sessionEnd = { | ||
| timestamp: new Date(), | ||
| type: "session_end", | ||
| cost: costSummary, | ||
| }; | ||
| await persistSessionEvent(sessionId, sessionEnd); | ||
| outputEvent(sessionEnd); | ||
| await onStop(); | ||
| } | ||
| /** | ||
| * Output an event as JSON Lines format. | ||
| * Each event is a single line of JSON. | ||
| * @param {object} event | ||
| * @param {AgentEvent} event | ||
| */ | ||
| function outputEvent(event) { | ||
| console.log(JSON.stringify(event)); | ||
| const { timestamp, ...rest } = event; | ||
| console.log( | ||
| JSON.stringify({ | ||
| timestamp: timestamp.toISOString(), | ||
| ...rest, | ||
| ...(rest.type === "error" | ||
| ? { error: { message: rest.error.message } } | ||
| : {}), | ||
| }), | ||
| ); | ||
| } |
| /** | ||
| * @import { Agent } from "../agent" | ||
| * @import { CostTracker } from "../metrics/costTracker.mjs"; | ||
| * @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs" | ||
@@ -29,2 +30,3 @@ */ | ||
| * @property {Agent} agent | ||
| * @property {CostTracker} costTracker | ||
| * @property {ClaudeCodePlugin[] | undefined} claudeCodePlugins | ||
@@ -42,2 +44,3 @@ * @property {string} helpMessage | ||
| agent, | ||
| costTracker, | ||
| claudeCodePlugins, | ||
@@ -133,3 +136,3 @@ helpMessage, | ||
| if (inputTrimmed.toLowerCase() === "/cost") { | ||
| const summary = agent.getCostSummary(); | ||
| const summary = costTracker.calculateCost(); | ||
| console.log(formatCostSummary(summary)); | ||
@@ -136,0 +139,0 @@ return "prompt"; |
+3
-3
| /** | ||
| * @import { UsageRecord } from "../usageStore.mjs" | ||
| * @import { UsageRecord } from "../metrics/usageStore.mjs" | ||
| */ | ||
| import { styleText } from "node:util"; | ||
| import * as usageStore from "../usageStore.mjs"; | ||
| import * as usageStore from "../metrics/usageStore.mjs"; | ||
@@ -278,3 +278,3 @@ /** | ||
| * @param {{ from: string | null, to: string | null }} args | ||
| * @param {{ readUsageRecords?: typeof import("../usageStore.mjs").readUsageRecords }} [deps] | ||
| * @param {{ readUsageRecords?: typeof import("../metrics/usageStore.mjs").readUsageRecords }} [deps] | ||
| * @returns {Promise<number>} exit code | ||
@@ -281,0 +281,0 @@ */ |
@@ -336,3 +336,3 @@ /** | ||
| * Format cost summary for interactive display | ||
| * @param {import("../costTracker.mjs").CostSummary} summary | ||
| * @param {import("../metrics/costTracker.mjs").CostSummary} summary | ||
| * @returns {string} | ||
@@ -374,24 +374,2 @@ */ | ||
| /** | ||
| * Format cost for batch mode JSON output | ||
| * @param {import("../costTracker.mjs").CostSummary} summary | ||
| */ | ||
| export function formatCostForBatch(summary) { | ||
| if (!summary || Object.keys(summary.breakdown).length === 0) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| total: summary.totalCost, | ||
| currency: summary.currency, | ||
| unit: summary.unit, | ||
| breakdown: Object.fromEntries( | ||
| Object.entries(summary.breakdown).map(([key, { tokens, cost }]) => [ | ||
| key, | ||
| { tokens, cost }, | ||
| ]), | ||
| ), | ||
| }; | ||
| } | ||
| /** | ||
| * Print a message to the console. | ||
@@ -398,0 +376,0 @@ * @param {Message} message |
+90
-108
| /** | ||
| * @import { Interface } from "node:readline" | ||
| * @import { Agent } from "../agent" | ||
| * @import { CostTracker } from "../metrics/costTracker.mjs"; | ||
| * @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs" | ||
| * @import { Tool, SandboxModeProvider } from "../tool" | ||
| * @import { PartialMessageContent } from "../model" | ||
| */ | ||
@@ -9,4 +12,4 @@ | ||
| import { styleText } from "node:util"; | ||
| import { appendUsageRecord, buildUsageRecord } from "../metrics/usageStore.mjs"; | ||
| import { persistSessionEvent, sessionFileExists } from "../sessionStore.mjs"; | ||
| import { appendUsageRecord, buildUsageRecord } from "../usageStore.mjs"; | ||
| import { notify } from "../utils/notify.mjs"; | ||
@@ -60,4 +63,5 @@ import { createCommandHandler } from "./commands.mjs"; | ||
| * @property {Date} startTime | ||
| * @property {boolean} sandbox | ||
| * @property {{ command: string; args?: string[] } | undefined} notifyCmd | ||
| * @property {boolean} sandbox | ||
| * @property {CostTracker} costTracker | ||
| * @property {() => Promise<void>} onStop | ||
@@ -69,29 +73,2 @@ * @property {ClaudeCodePlugin[]} [claudeCodePlugins] | ||
| /** | ||
| * Persist the session's cost summary to the usage log. | ||
| * Failures are logged but never thrown so exit is not blocked. | ||
| * | ||
| * @param {import("../costTracker.mjs").CostSummary} summary | ||
| * @param {{ sessionId: string, modelName: string, startTime: Date }} meta | ||
| */ | ||
| async function persistUsage(summary, { sessionId, modelName, startTime }) { | ||
| try { | ||
| const record = buildUsageRecord({ | ||
| sessionId, | ||
| mode: "interactive", | ||
| modelName, | ||
| workingDir: process.cwd(), | ||
| costSummary: summary, | ||
| now: startTime, | ||
| }); | ||
| if (!record) return; | ||
| await appendUsageRecord(record); | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error( | ||
| styleText("yellow", `Warning: failed to record usage: ${message}`), | ||
| ); | ||
| } | ||
| } | ||
| /** | ||
| * @param {CliOptions} options | ||
@@ -106,2 +83,3 @@ */ | ||
| sandbox, | ||
| costTracker, | ||
| onStop, | ||
@@ -111,49 +89,24 @@ claudeCodePlugins, | ||
| }) { | ||
| /** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string, toolSpinnerIndex: number, toolSpinnerLastTime: number }} */ | ||
| const state = { | ||
| turn: true, | ||
| /** @type {string[] | null} */ | ||
| multiLineBuffer: null, | ||
| subagentName: agent.getActiveSubagent()?.name ?? "", | ||
| toolSpinnerIndex: 0, | ||
| toolSpinnerLastTime: 0, | ||
| }; | ||
| spinnerIndex: 0, | ||
| spinnerLastTime: 0, | ||
| isExiting: false, | ||
| const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; | ||
| const SPINNER_INTERVAL_MS = 80; | ||
| // Create the stream buffer instance for this session | ||
| const streamBuffer = createStreamBuffer(); | ||
| const getCliPrompt = (subagentName = "", flashMessage = "") => | ||
| [ | ||
| "", | ||
| styleText( | ||
| ["white", "bgGray"], | ||
| [ | ||
| ...(subagentName ? [`[${subagentName}]`] : []), | ||
| `session: ${sessionId} | model: ${modelName} | sandbox: ${sandbox ? "on" : "off"}`, | ||
| ].join(" "), | ||
| ), | ||
| ...(flashMessage ? [flashMessage] : []), | ||
| "> ", | ||
| ].join("\n"); | ||
| // Cleanup handler to disable bracketed paste mode on exit | ||
| const cleanup = () => { | ||
| if (process.stdout.isTTY) { | ||
| process.stdout.write("\x1b[?2004l"); | ||
| } | ||
| /** Double-press Ctrl-D exit confirmation */ | ||
| lastCtrlDAttempt: new Date(0).getTime(), | ||
| }; | ||
| // Handle exit signals | ||
| let isExiting = false; | ||
| const handleExit = async () => { | ||
| if (isExiting) return; | ||
| isExiting = true; | ||
| if (state.isExiting) return; | ||
| state.isExiting = true; | ||
| cleanup(); | ||
| const summary = agent.getCostSummary(); | ||
| const summary = costTracker.calculateCost(); | ||
| const hasSessionFile = await sessionFileExists(sessionId); | ||
| if (hasSessionFile) { | ||
| await persistSessionEvent(sessionId, { | ||
| timestamp: new Date(), | ||
| type: "session_end", | ||
@@ -163,3 +116,23 @@ cost: summary, | ||
| } | ||
| await persistUsage(summary, { sessionId, modelName, startTime }); | ||
| const record = buildUsageRecord({ | ||
| sessionId, | ||
| mode: "interactive", | ||
| modelName, | ||
| workingDir: process.cwd(), | ||
| costSummary: summary, | ||
| now: startTime, | ||
| }); | ||
| if (record) { | ||
| const err = await appendUsageRecord(record); | ||
| if (err) { | ||
| console.error( | ||
| styleText( | ||
| "yellow", | ||
| `Warning: failed to record usage: ${err.message}`, | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
| console.log( | ||
@@ -176,7 +149,20 @@ [ | ||
| // Double-press Ctrl-D exit confirmation | ||
| let lastCtrlDAttempt = 0; | ||
| const EXIT_CONFIRM_TIMEOUT = 1500; | ||
| process.on("SIGTERM", handleExit); | ||
| process.on("SIGHUP", handleExit); | ||
| /** @type {import("node:readline").Interface} */ | ||
| const getCliPrompt = (subagentName = "", flashMessage = "") => | ||
| [ | ||
| "", | ||
| styleText( | ||
| ["white", "bgGray"], | ||
| [ | ||
| ...(subagentName ? [`[${subagentName}]`] : []), | ||
| `session: ${sessionId} | model: ${modelName} | sandbox: ${sandbox ? "on" : "off"}`, | ||
| ].join(" "), | ||
| ), | ||
| ...(flashMessage ? [flashMessage] : []), | ||
| "> ", | ||
| ].join("\n"); | ||
| /** @type {Interface} */ | ||
| let cli; | ||
@@ -225,3 +211,3 @@ | ||
| // Reset Ctrl-D confirmation when Ctrl-C is pressed | ||
| lastCtrlDAttempt = 0; | ||
| state.lastCtrlDAttempt = 0; | ||
| }; | ||
@@ -236,7 +222,7 @@ | ||
| const now = Date.now(); | ||
| if (now - lastCtrlDAttempt < EXIT_CONFIRM_TIMEOUT) { | ||
| if (now - state.lastCtrlDAttempt < 1500) { | ||
| handleExit(); | ||
| return; | ||
| } | ||
| lastCtrlDAttempt = now; | ||
| state.lastCtrlDAttempt = now; | ||
| if (state.turn) { | ||
@@ -255,2 +241,8 @@ cli.setPrompt( | ||
| // Setup stdin | ||
| readline.emitKeypressEvents(process.stdin); | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.setRawMode(true); | ||
| } | ||
| // Pre-readline pipeline: | ||
@@ -263,3 +255,2 @@ // stdin -> interrupt (Ctrl-C / Ctrl-D) -> paste (bracketed paste) -> readline | ||
| const paste = createPasteHandler(); | ||
| process.stdin.pipe(interrupt).pipe(paste.transform); | ||
@@ -270,2 +261,10 @@ | ||
| process.stdout.write("\x1b[?2004h"); | ||
| const disableBracketedPasteMode = () => { | ||
| if (process.stdout.isTTY) { | ||
| process.stdout.write("\x1b[?2004l"); | ||
| } | ||
| }; | ||
| process.on("exit", disableBracketedPasteMode); | ||
| process.on("SIGTERM", disableBracketedPasteMode); | ||
| process.on("SIGHUP", disableBracketedPasteMode); | ||
| } | ||
@@ -280,2 +279,3 @@ | ||
| }); | ||
| cli.on("close", handleExit); | ||
@@ -294,14 +294,5 @@ // Disable automatic prompt redraw on resize during agent turn | ||
| readline.emitKeypressEvents(process.stdin); | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.setRawMode(true); | ||
| } | ||
| // Handle readline close (e.g., stdin closed externally) | ||
| cli.on("close", handleExit); | ||
| process.on("SIGTERM", handleExit); | ||
| process.on("SIGHUP", handleExit); | ||
| const handleCommand = createCommandHandler({ | ||
| agent, | ||
| costTracker, | ||
| claudeCodePlugins, | ||
@@ -377,5 +368,9 @@ helpMessage: HELP_MESSAGE, | ||
| const outputStreamBuffer = createStreamBuffer(); | ||
| const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; | ||
| const spinnerIntervalMs = 80; | ||
| /** | ||
| * Render a streaming partial message content chunk. | ||
| * @param {import("../model").PartialMessageContent} partialContent | ||
| * @param {PartialMessageContent} partialContent | ||
| */ | ||
@@ -390,6 +385,6 @@ const handlePartialMessageContent = (partialContent) => { | ||
| if (["thinking", "tool_use"].includes(partialContent.type)) { | ||
| state.toolSpinnerIndex = 0; | ||
| state.toolSpinnerLastTime = Date.now(); | ||
| state.spinnerIndex = 0; | ||
| state.spinnerLastTime = Date.now(); | ||
| process.stdout.write( | ||
| `\n${subagentPrefix}${partialContentStr} ${styleText("cyan", SPINNER_FRAMES[0])}`, | ||
| `\n${subagentPrefix}${partialContentStr} ${styleText("cyan", spinnerFrames[0])}`, | ||
| ); | ||
@@ -403,12 +398,11 @@ } else { | ||
| const now = Date.now(); | ||
| if (now - state.toolSpinnerLastTime >= SPINNER_INTERVAL_MS) { | ||
| state.toolSpinnerIndex = | ||
| (state.toolSpinnerIndex + 1) % SPINNER_FRAMES.length; | ||
| state.toolSpinnerLastTime = now; | ||
| if (now - state.spinnerLastTime >= spinnerIntervalMs) { | ||
| state.spinnerIndex = (state.spinnerIndex + 1) % spinnerFrames.length; | ||
| state.spinnerLastTime = now; | ||
| process.stdout.write( | ||
| `\r\x1b[K${styleText("gray", `<${partialContent.type}>`)} ${styleText("cyan", SPINNER_FRAMES[state.toolSpinnerIndex])}`, | ||
| `\r\x1b[K${styleText("gray", `<${partialContent.type}>`)} ${styleText("cyan", spinnerFrames[state.spinnerIndex])}`, | ||
| ); | ||
| } | ||
| } else if (partialContent.type === "text") { | ||
| streamBuffer.feed(partialContent.content); | ||
| outputStreamBuffer.feed(partialContent.content); | ||
| } else { | ||
@@ -423,4 +417,3 @@ process.stdout.write(partialContent.content); | ||
| } else { | ||
| // Flush any buffered text before printing the closing tag | ||
| streamBuffer.forceFlush(); | ||
| outputStreamBuffer.forceFlush(); | ||
| console.log(styleText("gray", `\n</${partialContent.type}>`)); | ||
@@ -431,5 +424,2 @@ } | ||
| // Consume the agent's event stream. Because events are pulled one at a time | ||
| // and awaited in order, output is naturally serialized — no manual | ||
| // sequential executor is needed. | ||
| const consumeAgentEvents = async () => { | ||
@@ -489,4 +479,3 @@ for await (const event of agent.start()) { | ||
| case "turn_end": { | ||
| // Flush any remaining stream buffer content | ||
| streamBuffer.forceFlush(); | ||
| outputStreamBuffer.forceFlush(); | ||
@@ -500,5 +489,2 @@ const err = notify(notifyCmd); | ||
| // Defer prompt rendering to ensure terminal output is visible | ||
| await new Promise((resolve) => setImmediate(resolve)); | ||
| state.turn = true; | ||
@@ -511,9 +497,5 @@ cli.prompt(); | ||
| }; | ||
| consumeAgentEvents(); | ||
| cli.prompt(); | ||
| // Register cleanup handlers | ||
| process.on("exit", cleanup); | ||
| process.on("SIGTERM", cleanup); | ||
| } | ||
@@ -520,0 +502,0 @@ |
+1
-1
| import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs"; | ||
| import { ModelDefinition, PlatformConfig } from "./modelDefinition"; | ||
| import { ModelDefinition, PlatformConfig } from "./model.definition"; | ||
| import { ToolUsePattern } from "./tool"; | ||
@@ -4,0 +4,0 @@ import { ExecCommandSanboxConfig } from "./tools/execCommand"; |
+23
-11
@@ -28,3 +28,4 @@ /** | ||
| import { setupMCPServer } from "./mcp/integration.mjs"; | ||
| import { createModelCaller } from "./modelCaller.mjs"; | ||
| import { createCostTracker } from "./metrics/costTracker.mjs"; | ||
| import { createModelCaller } from "./model.mjs"; | ||
| import { createPrompt } from "./prompt.mjs"; | ||
@@ -441,3 +442,2 @@ import { listSessions, loadSession } from "./sessionStore.mjs"; | ||
| // Resolve context soft limit from autoCompact config | ||
| const contextSoftLimit = resolveContextSoftLimit( | ||
@@ -458,4 +458,15 @@ appConfig.autoCompact, | ||
| const costTracker = createCostTracker(modelDef.cost); | ||
| if (resumedState?.tokenUsageHistory) { | ||
| costTracker.restoreUsageHistory(resumedState.tokenUsageHistory); | ||
| } | ||
| const agent = createAgent({ | ||
| callModel: agentCallModel, | ||
| callModel: async (input) => { | ||
| const output = await agentCallModel(input); | ||
| if (!(output instanceof Error) && output.providerTokenUsage) { | ||
| costTracker.recordUsage(output.providerTokenUsage); | ||
| } | ||
| return output; | ||
| }, | ||
| prompt, | ||
@@ -465,3 +476,2 @@ tools: [...builtinTools, ...mcpTools], | ||
| agentRoles, | ||
| modelCostConfig: modelDef.cost, | ||
| sessionMetadata: { | ||
@@ -484,2 +494,3 @@ sessionId, | ||
| startTime, | ||
| costTracker, | ||
| onStop: async () => { | ||
@@ -501,10 +512,11 @@ for (const cleanup of mcpCleanups) { | ||
| }); | ||
| } else { | ||
| startInteractiveSession({ | ||
| ...sessionOptions, | ||
| execCommandTool, | ||
| notifyCmd: appConfig.notifyCmd, | ||
| claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []), | ||
| }); | ||
| return; | ||
| } | ||
| startInteractiveSession({ | ||
| ...sessionOptions, | ||
| execCommandTool, | ||
| notifyCmd: appConfig.notifyCmd, | ||
| claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []), | ||
| }); | ||
| } | ||
@@ -511,0 +523,0 @@ |
@@ -14,3 +14,3 @@ /** | ||
| /** | ||
| * @param {import("../modelDefinition").PlatformConfig} platformConfig | ||
| * @param {import("../model.definition").PlatformConfig} platformConfig | ||
| * @param {AnthropicModelConfig} modelConfig | ||
@@ -17,0 +17,0 @@ * @param {ModelInput} input |
@@ -13,3 +13,3 @@ /** | ||
| /** | ||
| * @param {import("../modelDefinition").PlatformConfig} platformConfig | ||
| * @param {import("../model.definition").PlatformConfig} platformConfig | ||
| * @param {BedrockConverseModelConfig} modelConfig | ||
@@ -16,0 +16,0 @@ * @param {ModelInput} input |
@@ -23,3 +23,3 @@ /** | ||
| * - https://ai.google.dev/api/caching | ||
| * @param {import("../modelDefinition").PlatformConfig} platformConfig | ||
| * @param {import("../model.definition").PlatformConfig} platformConfig | ||
| * @param {GeminiModelConfig} modelConfig | ||
@@ -26,0 +26,0 @@ * @returns {GeminiModelCaller} |
@@ -12,3 +12,3 @@ /** | ||
| /** | ||
| * @param {import("../modelDefinition").PlatformConfig} platformConfig | ||
| * @param {import("../model.definition").PlatformConfig} platformConfig | ||
| * @param {OpenAIModelConfig} modelConfig | ||
@@ -15,0 +15,0 @@ * @param {ModelInput} input |
@@ -16,3 +16,3 @@ /** | ||
| /** | ||
| * @param {import("../modelDefinition").PlatformConfig} platformConfig | ||
| * @param {import("../model.definition").PlatformConfig} platformConfig | ||
| * @param {OpenAICompatibleModelConfig} modelConfig | ||
@@ -19,0 +19,0 @@ * @param {ModelInput} input |
@@ -13,3 +13,3 @@ /** | ||
| /** Event types that are persisted in session JSONL streams. */ | ||
| const WRITABLE_EVENT_TYPES = new Set([ | ||
| export const PERSISTED_SESSION_EVENT_TYPES = new Set([ | ||
| "session_start", | ||
@@ -84,13 +84,15 @@ "message", | ||
| * @param {string} sessionId | ||
| * @param {{ type: string, [key: string]: unknown }} event | ||
| * @param {import("./agent").AgentEvent} event | ||
| * @param {{ dir?: string }} [options] | ||
| */ | ||
| export async function persistSessionEvent(sessionId, event, options = {}) { | ||
| if (!WRITABLE_EVENT_TYPES.has(event.type)) return; | ||
| if (!PERSISTED_SESSION_EVENT_TYPES.has(event.type)) return; | ||
| const dir = options.dir ?? SESSIONS_DIR; | ||
| await fs.mkdir(dir, { recursive: true }); | ||
| const { timestamp, ...rest } = event; | ||
| const line = JSON.stringify({ | ||
| ...event, | ||
| timestamp: new Date().toISOString(), | ||
| timestamp: event.timestamp.toISOString(), | ||
| ...rest, | ||
| }); | ||
@@ -97,0 +99,0 @@ await fs.appendFile(sessionFilePath(sessionId, { dir }), `${line}\n`, "utf8"); |
| /** | ||
| * @import { ProviderTokenUsage } from "./model" | ||
| */ | ||
| /** | ||
| * @typedef {Object} TokenBreakdown | ||
| * @property {number} tokens - Token count | ||
| * @property {number | undefined} cost - Cost (undefined if no pricing) | ||
| */ | ||
| /** | ||
| * @typedef {Object} CostSummary | ||
| * @property {string} currency - Currency code (e.g., "USD") | ||
| * @property {string} unit - Unit size (e.g., "1M") | ||
| * @property {Record<string, TokenBreakdown>} breakdown - Token breakdown | ||
| * @property {number | undefined} totalCost - Total cost (undefined if no pricing) | ||
| */ | ||
| /** | ||
| * @typedef {Object} CostConfig | ||
| * @property {string} currency | ||
| * @property {string} unit | ||
| * @property {Record<string, number>} prices | ||
| */ | ||
| /** | ||
| * @typedef {Object} CostTracker | ||
| * @property {(usage: ProviderTokenUsage) => void} recordUsage - Record token usage | ||
| * @property {() => Record<string, number>} getAggregatedUsage - Get aggregated usage | ||
| * @property {() => CostSummary} calculateCost - Calculate cost summary | ||
| * @property {() => boolean} hasUsage - Check if any usage recorded | ||
| * @property {() => ProviderTokenUsage[]} getUsageHistory - Get a snapshot of the raw usage history | ||
| * @property {(history: ProviderTokenUsage[]) => void} restoreUsageHistory - Replace the usage history (used when resuming a saved session) | ||
| */ | ||
| /** | ||
| * Validate a cost configuration object at runtime. | ||
| * @param {unknown} config | ||
| */ | ||
| function validateCostConfig(config) { | ||
| if (config === undefined) return; | ||
| if (typeof config !== "object" || config === null) { | ||
| throw new TypeError("CostConfig must be an object"); | ||
| } | ||
| const c = /** @type {Record<string, unknown>} */ (config); | ||
| if (typeof c.currency !== "string") { | ||
| throw new TypeError("CostConfig.currency must be a string"); | ||
| } | ||
| if (typeof c.unit !== "string") { | ||
| throw new TypeError("CostConfig.unit must be a string"); | ||
| } | ||
| if (typeof c.prices !== "object" || c.prices === null) { | ||
| throw new TypeError("CostConfig.prices must be an object"); | ||
| } | ||
| for (const [key, value] of Object.entries( | ||
| /** @type {Record<string, unknown>} */ (c.prices), | ||
| )) { | ||
| if (typeof value !== "number") { | ||
| throw new TypeError( | ||
| `CostConfig.prices["${key}"] must be a number, got ${typeof value}`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Create a cost tracker for session token usage | ||
| * @param {CostConfig} [costConfig] - Optional cost configuration | ||
| * @returns {CostTracker} | ||
| */ | ||
| export function createCostTracker(costConfig) { | ||
| validateCostConfig(costConfig); | ||
| /** @type {ProviderTokenUsage[]} */ | ||
| const usageHistory = []; | ||
| /** | ||
| * Record token usage from a provider. | ||
| * Throws when usage is not a non-null object. | ||
| * @param {ProviderTokenUsage} usage | ||
| * @throws {TypeError} when usage is null, undefined, or not an object | ||
| */ | ||
| function recordUsage(usage) { | ||
| if (typeof usage !== "object" || usage === null) { | ||
| throw new TypeError("usage must be a non-null object"); | ||
| } | ||
| usageHistory.push(usage); | ||
| } | ||
| /** | ||
| * Get aggregated token usage | ||
| * @returns {Record<string, number>} | ||
| */ | ||
| function getAggregatedUsage() { | ||
| return aggregateTokens(usageHistory); | ||
| } | ||
| /** | ||
| * Calculate cost summary | ||
| * @returns {CostSummary} | ||
| */ | ||
| function calculateCost() { | ||
| const aggregated = aggregateTokens(usageHistory); | ||
| return calculateCostFromConfig(aggregated, costConfig); | ||
| } | ||
| /** | ||
| * Check if any usage recorded | ||
| * @returns {boolean} | ||
| */ | ||
| function hasUsage() { | ||
| return usageHistory.length > 0; | ||
| } | ||
| /** | ||
| * Get a snapshot copy of the raw usage history. | ||
| * @returns {ProviderTokenUsage[]} | ||
| */ | ||
| function getUsageHistory() { | ||
| return usageHistory.map((u) => u); | ||
| } | ||
| /** | ||
| * Replace the usage history. Used when resuming a saved session. | ||
| * @param {ProviderTokenUsage[]} history | ||
| */ | ||
| function restoreUsageHistory(history) { | ||
| if (!Array.isArray(history)) { | ||
| throw new TypeError("history must be an array"); | ||
| } | ||
| usageHistory.length = 0; | ||
| for (const usage of history) { | ||
| if (typeof usage !== "object" || usage === null) { | ||
| throw new TypeError("each usage entry must be a non-null object"); | ||
| } | ||
| usageHistory.push(usage); | ||
| } | ||
| } | ||
| return Object.freeze({ | ||
| recordUsage, | ||
| getAggregatedUsage, | ||
| calculateCost, | ||
| hasUsage, | ||
| getUsageHistory, | ||
| restoreUsageHistory, | ||
| }); | ||
| } | ||
| /** | ||
| * Aggregate token usage history by key | ||
| * @param {ProviderTokenUsage[]} usageHistory | ||
| * @returns {Record<string, number>} | ||
| */ | ||
| function aggregateTokens(usageHistory) { | ||
| /** @type {Record<string, number>} */ | ||
| const aggregated = {}; | ||
| for (const usage of usageHistory) { | ||
| recursivelySumValues(usage, [], aggregated); | ||
| } | ||
| return aggregated; | ||
| } | ||
| /** | ||
| * Recursively sum numeric values in token usage | ||
| * @param {ProviderTokenUsage} obj | ||
| * @param {string[]} path | ||
| * @param {Record<string, number>} result | ||
| */ | ||
| function recursivelySumValues(obj, path, result) { | ||
| for (const [key, value] of Object.entries(obj)) { | ||
| const currentPath = [...path, key]; | ||
| const pathStr = currentPath.join("."); | ||
| if (typeof value === "number") { | ||
| result[pathStr] = (result[pathStr] || 0) + value; | ||
| } else if ( | ||
| typeof value === "object" && | ||
| value !== null && | ||
| !Array.isArray(value) | ||
| ) { | ||
| recursivelySumValues(value, currentPath, result); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Calculate cost from aggregated tokens and config | ||
| * @param {Record<string, number>} aggregated | ||
| * @param {CostConfig | undefined} config | ||
| * @returns {CostSummary} | ||
| */ | ||
| function calculateCostFromConfig(aggregated, config) { | ||
| /** @type {Record<string, TokenBreakdown>} */ | ||
| const breakdown = {}; | ||
| let totalCost = 0; | ||
| for (const [key, tokens] of Object.entries(aggregated)) { | ||
| breakdown[key] = Object.freeze({ tokens, cost: undefined }); | ||
| if (!config?.prices?.[key]) { | ||
| continue; | ||
| } | ||
| const costValue = config.prices[key]; | ||
| const unitSize = parseUnit(config.unit); | ||
| if (typeof costValue !== "number") { | ||
| throw new TypeError( | ||
| `config.prices["${key}"] must be a number, got ${typeof costValue}`, | ||
| ); | ||
| } | ||
| const cost = (tokens * costValue) / unitSize; | ||
| breakdown[key] = Object.freeze({ tokens, cost }); | ||
| totalCost += cost; | ||
| } | ||
| return Object.freeze({ | ||
| currency: config?.currency ?? "USD", | ||
| unit: config?.unit ?? "1M", | ||
| breakdown, | ||
| totalCost: config?.prices ? totalCost : undefined, | ||
| }); | ||
| } | ||
| /** | ||
| * Parse unit string to number. | ||
| * @param {string} unit | ||
| * @returns {number} | ||
| * @throws {Error} when the unit is not recognized | ||
| */ | ||
| function parseUnit(unit) { | ||
| if (unit === "1M") return 1_000_000; | ||
| if (unit === "1K") return 1_000; | ||
| throw new Error(`Unknown cost unit: "${unit}"`); | ||
| } |
| import { callAnthropicModel } from "./providers/anthropic.mjs"; | ||
| import { callBedrockConverseModel } from "./providers/bedrock.mjs"; | ||
| import { createCacheEnabledGeminiModelCaller } from "./providers/gemini.mjs"; | ||
| import { callOpenAIModel } from "./providers/openai.mjs"; | ||
| import { callOpenAICompatibleModel } from "./providers/openaiCompatible.mjs"; | ||
| /** | ||
| * @param {import("./modelDefinition").ModelDefinition} modelDef | ||
| * @returns {import("./model").CallModel} | ||
| */ | ||
| export function createModelCaller(modelDef) { | ||
| const { platform, model } = modelDef; | ||
| switch (model.format) { | ||
| case "anthropic": | ||
| return (input) => callAnthropicModel(platform, model.config, input); | ||
| case "gemini": { | ||
| const modelCaller = createCacheEnabledGeminiModelCaller( | ||
| platform, | ||
| model.config, | ||
| ); | ||
| return (input) => modelCaller(model.config, input); | ||
| } | ||
| case "openai-responses": | ||
| return (input) => callOpenAIModel(platform, model.config, input); | ||
| case "openai-messages": | ||
| return (input) => | ||
| callOpenAICompatibleModel(platform, model.config, input); | ||
| case "bedrock-converse": | ||
| return (input) => callBedrockConverseModel(platform, model.config, input); | ||
| } | ||
| } |
| import { AnthropicModelConfig } from "./providers/anthropic"; | ||
| import { BedrockConverseModelConfig } from "./providers/bedrock"; | ||
| import { GeminiModelConfig } from "./providers/gemini"; | ||
| import { OpenAIModelConfig } from "./providers/openai"; | ||
| import { OpenAICompatibleModelConfig } from "./providers/openaiCompatible"; | ||
| export type ModelDefinition = { | ||
| name: string; | ||
| variant: string; | ||
| platform: PlatformConfig; | ||
| model: ModelConfig; | ||
| cost?: CostConfig; | ||
| autoCompact?: ModelAutoCompactConfig; | ||
| }; | ||
| export type ModelAutoCompactConfig = { | ||
| /** Keys in providerTokenUsage whose values are summed to get the input token count. */ | ||
| inputTokensKeys: string[]; | ||
| }; | ||
| export type PlatformConfig = | ||
| | { | ||
| name: "anthropic"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| apiKey: string; | ||
| } | ||
| | { | ||
| name: "gemini"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| apiKey: string; | ||
| } | ||
| | { | ||
| name: "openai"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| apiKey: string; | ||
| } | ||
| | { | ||
| name: "openai-compatible"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| apiKey: string; | ||
| } | ||
| | { | ||
| name: "azure"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| azureConfigDir?: string; | ||
| } | ||
| | { | ||
| name: "bedrock"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| awsProfile: string; | ||
| } | ||
| | { | ||
| name: "vertex-ai"; | ||
| variant: string; | ||
| baseURL: string; | ||
| customHeaders?: Record<string, string>; | ||
| account?: string; | ||
| }; | ||
| export type ModelConfig = | ||
| | { | ||
| format: "anthropic"; | ||
| config: AnthropicModelConfig; | ||
| } | ||
| | { | ||
| format: "gemini"; | ||
| config: GeminiModelConfig; | ||
| } | ||
| | { | ||
| format: "openai-responses"; | ||
| config: OpenAIModelConfig; | ||
| } | ||
| | { | ||
| format: "openai-messages"; | ||
| config: OpenAICompatibleModelConfig; | ||
| } | ||
| | { | ||
| format: "bedrock-converse"; | ||
| config: BedrockConverseModelConfig; | ||
| }; | ||
| export type CostConfig = { | ||
| currency: string; | ||
| unit: string; | ||
| prices: Record<string, number>; | ||
| }; |
| /** | ||
| * @import { ProviderTokenUsage } from "./model" | ||
| */ | ||
| /** | ||
| * Extract the input (prompt/context) token count from a single turn's | ||
| * provider token usage object by summing the values of the specified keys. | ||
| * | ||
| * Returns `undefined` when no specified key yields a positive number. | ||
| * | ||
| * @param {ProviderTokenUsage} usage | ||
| * @param {string[]} inputTokensKeys - Keys whose numeric values are summed. | ||
| * @returns {number | undefined} | ||
| */ | ||
| export function extractInputTokenCount(usage, inputTokensKeys) { | ||
| let total = 0; | ||
| let found = false; | ||
| for (const key of inputTokensKeys) { | ||
| const value = usage[key]; | ||
| if (typeof value === "number" && value > 0) { | ||
| total += value; | ||
| found = true; | ||
| } | ||
| } | ||
| return found ? total : undefined; | ||
| } |
| /** | ||
| * @import { CostSummary } from "./costTracker.mjs" | ||
| */ | ||
| import fs from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { USAGE_LOG_PATH } from "./env.mjs"; | ||
| /** | ||
| * @typedef {Object} UsageRecord | ||
| * @property {string} timestamp - ISO 8601 timestamp | ||
| * @property {string} sessionId | ||
| * @property {"interactive" | "batch"} mode | ||
| * @property {string} modelName - e.g. "claude-sonnet-4-6+thinking-high" | ||
| * @property {string} workingDir | ||
| * @property {string} currency - e.g. "USD" | ||
| * @property {string} unit - e.g. "1M" | ||
| * @property {number | null} totalCost - null when no pricing configured | ||
| * @property {Record<string, number>} tokens - aggregated token counts by path | ||
| */ | ||
| /** | ||
| * Maximum size (in bytes) of a single JSONL line. | ||
| * Linux guarantees atomicity of O_APPEND writes up to PIPE_BUF (4096 bytes), | ||
| * so we enforce a smaller limit to stay safely under that threshold even | ||
| * with multi-byte UTF-8 characters in model/session names. | ||
| */ | ||
| const MAX_RECORD_BYTES = 3072; | ||
| /** | ||
| * Append a usage record to the persistent usage log. | ||
| * | ||
| * On POSIX systems, `fs.appendFile` opens the file with `O_APPEND`, which | ||
| * guarantees that each write lands at end-of-file and is atomic when the | ||
| * payload is <= PIPE_BUF (4096 bytes on Linux). We write the record as a | ||
| * single call to preserve this guarantee even if multiple plain-agent | ||
| * sessions finish simultaneously. | ||
| * | ||
| * @param {UsageRecord} record | ||
| * @param {{ path?: string }} [options] | ||
| * @returns {Promise<void>} | ||
| */ | ||
| export async function appendUsageRecord(record, options = {}) { | ||
| const filePath = options.path ?? USAGE_LOG_PATH; | ||
| const line = `${JSON.stringify(record)}\n`; | ||
| const bytes = Buffer.byteLength(line, "utf8"); | ||
| if (bytes > MAX_RECORD_BYTES) { | ||
| throw new Error( | ||
| `Usage record exceeds ${MAX_RECORD_BYTES} bytes (${bytes}); refusing to write to keep appends atomic.`, | ||
| ); | ||
| } | ||
| await fs.mkdir(path.dirname(filePath), { recursive: true }); | ||
| await fs.appendFile(filePath, line, { encoding: "utf8" }); | ||
| } | ||
| /** | ||
| * Read all usage records from the log file. | ||
| * Malformed lines are skipped and collected in `skipped`. | ||
| * | ||
| * @param {{ path?: string }} [options] | ||
| * @returns {Promise<{ records: UsageRecord[], skipped: { line: number, reason: string }[] }>} | ||
| */ | ||
| export async function readUsageRecords(options = {}) { | ||
| const filePath = options.path ?? USAGE_LOG_PATH; | ||
| /** @type {string} */ | ||
| let content; | ||
| try { | ||
| content = await fs.readFile(filePath, "utf8"); | ||
| } catch (err) { | ||
| if ( | ||
| err instanceof Error && | ||
| /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT" | ||
| ) { | ||
| return { records: [], skipped: [] }; | ||
| } | ||
| throw err; | ||
| } | ||
| /** @type {UsageRecord[]} */ | ||
| const records = []; | ||
| /** @type {{ line: number, reason: string }[]} */ | ||
| const skipped = []; | ||
| const lines = content.split("\n"); | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const raw = lines[i]; | ||
| if (raw.length === 0) continue; | ||
| try { | ||
| const parsed = JSON.parse(raw); | ||
| if (!isUsageRecord(parsed)) { | ||
| skipped.push({ line: i + 1, reason: "invalid shape" }); | ||
| continue; | ||
| } | ||
| records.push(parsed); | ||
| } catch (err) { | ||
| const reason = err instanceof Error ? err.message : String(err); | ||
| skipped.push({ line: i + 1, reason }); | ||
| } | ||
| } | ||
| return { records, skipped }; | ||
| } | ||
| /** | ||
| * Build a usage record from a finished session's cost summary. | ||
| * Returns null when there's nothing worth recording (no tokens). | ||
| * | ||
| * @param {Object} args | ||
| * @param {string} args.sessionId | ||
| * @param {"interactive" | "batch"} args.mode | ||
| * @param {string} args.modelName | ||
| * @param {string} args.workingDir | ||
| * @param {CostSummary} args.costSummary | ||
| * @param {Date} [args.now] | ||
| * @returns {UsageRecord | null} | ||
| */ | ||
| export function buildUsageRecord({ | ||
| sessionId, | ||
| mode, | ||
| modelName, | ||
| workingDir, | ||
| costSummary, | ||
| now, | ||
| }) { | ||
| /** @type {Record<string, number>} */ | ||
| const tokens = {}; | ||
| for (const [key, entry] of Object.entries(costSummary.breakdown)) { | ||
| tokens[key] = entry.tokens; | ||
| } | ||
| if (Object.keys(tokens).length === 0) { | ||
| return null; | ||
| } | ||
| const timestamp = (now ?? new Date()).toISOString(); | ||
| return { | ||
| timestamp, | ||
| sessionId, | ||
| mode, | ||
| modelName, | ||
| workingDir, | ||
| currency: costSummary.currency, | ||
| unit: costSummary.unit, | ||
| totalCost: | ||
| costSummary.totalCost === undefined ? null : costSummary.totalCost, | ||
| tokens, | ||
| }; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {value is UsageRecord} | ||
| */ | ||
| function isUsageRecord(value) { | ||
| if (typeof value !== "object" || value === null) return false; | ||
| const r = /** @type {Record<string, unknown>} */ (value); | ||
| return ( | ||
| typeof r.timestamp === "string" && | ||
| typeof r.sessionId === "string" && | ||
| (r.mode === "interactive" || r.mode === "batch") && | ||
| typeof r.modelName === "string" && | ||
| typeof r.workingDir === "string" && | ||
| typeof r.currency === "string" && | ||
| typeof r.unit === "string" && | ||
| (r.totalCost === null || typeof r.totalCost === "number") && | ||
| typeof r.tokens === "object" && | ||
| r.tokens !== null | ||
| ); | ||
| } |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
607004
0.13%17266
0.54%94
-1.05%1036
-0.58%