diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1112ab433..b2febf8ec 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added usage tracking system with normalized schema for provider quota/limit endpoints @@ -12,8 +11,16 @@ - Added OpenAI Codex usage provider for primary and secondary rate limit windows - Added ZAI usage provider for token and request quota tracking +### Changed + +- Updated Claude usage provider to extract account identifiers from response headers +- Updated GitHub Copilot usage provider to include account identifiers in usage reports +- Updated Google Gemini CLI usage provider to handle missing reset time gracefully + ### Fixed +- Fixed GitHub Copilot usage provider to simplify token handling and improve reliability +- Fixed GitHub Copilot usage provider to properly resolve account identifiers for OAuth credentials - Fixed API validation errors when sending empty user messages (resume with `.`) across all providers: - Google Cloud Code Assist (google-shared.ts) - OpenAI Responses API (openai-responses.ts) @@ -21,6 +28,10 @@ - Cursor (cursor.ts) - Amazon Bedrock (amazon-bedrock.ts) - Clamped OpenAI Codex reasoning effort "minimal" to "low" for gpt-5.2 models to avoid API errors +- Fixed GitHub Copilot usage fallback to internal quota endpoints when billing usage is unavailable +- Fixed GitHub Copilot usage metadata to include account identifiers for report dedupe +- Fixed Anthropic usage metadata extraction to include account identifiers when provided by the usage endpoint +- Fixed Gemini CLI usage windows to consistently label quota windows for display suppression ## [6.9.69] - 2026-01-21 ### Added diff --git a/packages/ai/src/providers/google-gemini-cli-usage.ts b/packages/ai/src/providers/google-gemini-cli-usage.ts index b320185b4..0c2b62cc8 100644 --- a/packages/ai/src/providers/google-gemini-cli-usage.ts +++ b/packages/ai/src/providers/google-gemini-cli-usage.ts @@ -73,10 +73,20 @@ function getModelTier(modelId: string): string | undefined { return undefined; } -function parseWindow(resetTime: string | undefined, now: number): UsageWindow | undefined { - if (!resetTime) return undefined; +function parseWindow(resetTime: string | undefined, now: number): UsageWindow { + if (!resetTime) { + return { + id: "quota", + label: "Quota window", + }; + } const resetsAt = Date.parse(resetTime); - if (Number.isNaN(resetsAt)) return undefined; + if (Number.isNaN(resetsAt)) { + return { + id: "quota", + label: "Quota window", + }; + } return { id: `reset-${resetsAt}`, label: "Quota window", diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index 6ea9d478c..6becd9d10 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -62,6 +62,11 @@ interface ClaudeUsageResponse { seven_day_sonnet?: ClaudeUsageBucket | null; } +type ClaudeUsagePayload = { + payload: ClaudeUsageResponse; + orgId?: string; +}; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -94,6 +99,28 @@ function parseBucket(bucket: unknown): ParsedUsageBucket | undefined { return { utilization, resetsAt }; } +function getPayloadString(payload: Record, key: string): string | undefined { + const value = payload[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { accountId?: string; email?: string } { + if (!isRecord(payload)) return { accountId: orgId }; + const accountId = + getPayloadString(payload, "account_id") ?? + getPayloadString(payload, "accountId") ?? + getPayloadString(payload, "user_id") ?? + getPayloadString(payload, "userId") ?? + getPayloadString(payload, "org_id") ?? + getPayloadString(payload, "orgId") ?? + orgId; + const email = + getPayloadString(payload, "email") ?? + getPayloadString(payload, "user_email") ?? + getPayloadString(payload, "userEmail"); + return { accountId, email }; +} + function hasUsageData(payload: ClaudeUsageResponse): boolean { return Boolean(payload.five_hour || payload.seven_day || payload.seven_day_opus || payload.seven_day_sonnet); } @@ -103,8 +130,9 @@ async function fetchUsagePayload( headers: Record, ctx: UsageFetchContext, signal?: AbortSignal, -): Promise { +): Promise { let lastPayload: ClaudeUsageResponse | null = null; + let lastOrgId: string | undefined; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const response = await ctx.fetch(url, { headers, signal }); @@ -114,8 +142,10 @@ async function fetchUsagePayload( } const payload = (await response.json()) as ClaudeUsageResponse; lastPayload = payload; + const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined; + lastOrgId = orgId ?? lastOrgId; if (payload && isRecord(payload) && hasUsageData(payload)) { - return payload; + return { payload, orgId }; } } catch (error) { ctx.logger?.warn("Claude usage fetch error", { error: String(error) }); @@ -127,7 +157,7 @@ async function fetchUsagePayload( } } - return lastPayload; + return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null; } function buildUsageAmount(utilization: number | undefined): UsageAmount | undefined { @@ -240,8 +270,9 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext authorization: `Bearer ${credential.accessToken}`, }; - const payload = await fetchUsagePayload(url, headers, ctx, params.signal); - if (!payload || !isRecord(payload)) return cachedValue; + const payloadResult = await fetchUsagePayload(url, headers, ctx, params.signal); + if (!payloadResult || !isRecord(payloadResult.payload)) return cachedValue; + const { payload, orgId } = payloadResult; const fiveHour = parseBucket(payload.five_hour); const sevenDay = parseBucket(payload.seven_day); @@ -296,14 +327,17 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext ].filter((limit): limit is UsageLimit => limit !== null); if (limits.length === 0) return cachedValue; + const identity = extractUsageIdentity(payload, orgId); + const accountId = identity.accountId ?? credential.accountId; + const email = identity.email ?? credential.email; const report: UsageReport = { provider: params.provider, fetchedAt: now, limits, metadata: { - accountId: credential.accountId, - email: credential.email, + accountId, + email, endpoint: url, }, raw: payload, diff --git a/packages/ai/src/usage/github-copilot.ts b/packages/ai/src/usage/github-copilot.ts index c98d792d8..e71c739a2 100644 --- a/packages/ai/src/usage/github-copilot.ts +++ b/packages/ai/src/usage/github-copilot.ts @@ -49,11 +49,6 @@ type CopilotUsageResponse = { quota_snapshots: CopilotQuotaSnapshots; }; -type CopilotTokenResponse = { - token: string; - expires_at: number; -}; - type BillingUsageItem = { product: string; sku: string; @@ -101,13 +96,6 @@ function resolveGitHubApiBaseUrl(params: UsageFetchParams): string { return `https://api.${enterpriseUrl}`; } -function resolveCopilotApiBaseUrl(params: UsageFetchParams): string { - if (params.baseUrl) return params.baseUrl.replace(/\/$/, ""); - const enterpriseUrl = params.credential.enterpriseUrl?.trim(); - if (enterpriseUrl) return `https://api.${enterpriseUrl}`; - return "https://api.individual.githubcopilot.com"; -} - function buildCacheKey(params: UsageFetchParams): string { const parts: string[] = [params.provider]; const { credential } = params; @@ -221,68 +209,21 @@ async function resolveGitHubUsername( } } -async function exchangeForCopilotToken( - ctx: UsageFetchContext, - baseUrl: string, - oauthToken: string, - signal?: AbortSignal, -): Promise { - try { - const data = await fetchJson(ctx, `${baseUrl}/copilot_internal/v2/token`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${oauthToken}`, - ...COPILOT_HEADERS, - }, - signal, - }); - - if (!isRecord(data)) return null; - const token = typeof data.token === "string" ? data.token : undefined; - const expiresAt = toNumber(data.expires_at); - if (!token || !expiresAt) return null; - return { token, expires_at: expiresAt }; - } catch { - return null; - } -} - async function fetchInternalUsage( ctx: UsageFetchContext, - baseUrl: string, - oauthToken: string, - accessToken: string | undefined, - expiresAt: number | undefined, + githubApiBaseUrl: string, + token: string, signal?: AbortSignal, ): Promise { - const requestWithToken = async (token: string, legacy: boolean) => { - const headers: Record = { - "Content-Type": "application/json", - Accept: "application/json", - Authorization: legacy ? `token ${token}` : `Bearer ${token}`, - ...COPILOT_HEADERS, - }; - const data = await fetchJson(ctx, `${baseUrl}/copilot_internal/user`, { headers, signal }); - if (!isRecord(data)) throw new Error("Invalid Copilot usage response"); - return data as CopilotUsageResponse; + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...COPILOT_HEADERS, }; - - const now = ctx.now(); - if (accessToken && expiresAt && accessToken !== oauthToken && expiresAt > now) { - try { - return await requestWithToken(accessToken, false); - } catch { - // Ignore and try other strategies. - } - } - - try { - return await requestWithToken(oauthToken, true); - } catch { - const exchanged = await exchangeForCopilotToken(ctx, baseUrl, oauthToken, signal); - if (!exchanged) throw new Error("Copilot usage token exchange failed"); - return requestWithToken(exchanged.token, false); - } + const data = await fetchJson(ctx, `${githubApiBaseUrl}/copilot_internal/user`, { headers, signal }); + if (!isRecord(data)) throw new Error("Invalid Copilot usage response"); + return data as CopilotUsageResponse; } async function fetchBillingUsage( @@ -315,6 +256,7 @@ function buildLimitFromQuota( quota: CopilotQuotaDetail, plan: string, window: UsageWindow | undefined, + accountId?: string, ): UsageLimit { const used = quota.unlimited ? undefined : Math.max(0, quota.entitlement - quota.remaining); const limit = quota.unlimited ? undefined : quota.entitlement; @@ -329,6 +271,7 @@ function buildLimitFromQuota( label, scope: { provider: "github-copilot", + accountId, tier: plan, windowId: window?.id, }, @@ -342,21 +285,22 @@ function buildLimitFromQuota( function normalizeQuotaSnapshots( data: CopilotUsageResponse, now: number, + accountId?: string, ): { limits: UsageLimit[]; window?: UsageWindow } { const window = buildWindow(data.quota_reset_date, now); const snapshots = data.quota_snapshots ?? {}; const limits: UsageLimit[] = []; const premium = parseQuotaDetail(snapshots.premium_interactions); if (premium) { - limits.push(buildLimitFromQuota("premium", "Premium Requests", premium, data.copilot_plan, window)); + limits.push(buildLimitFromQuota("premium", "Premium Requests", premium, data.copilot_plan, window, accountId)); } const chat = parseQuotaDetail(snapshots.chat); if (chat && !chat.unlimited) { - limits.push(buildLimitFromQuota("chat", "Chat Requests", chat, data.copilot_plan, window)); + limits.push(buildLimitFromQuota("chat", "Chat Requests", chat, data.copilot_plan, window, accountId)); } const completions = parseQuotaDetail(snapshots.completions); if (completions && !completions.unlimited) { - limits.push(buildLimitFromQuota("completions", "Completions", completions, data.copilot_plan, window)); + limits.push(buildLimitFromQuota("completions", "Completions", completions, data.copilot_plan, window, accountId)); } return { limits, window }; } @@ -437,26 +381,36 @@ export const githubCopilotUsageProvider: UsageProvider = { const cached = await ctx.cache.get(cacheKey); if (cached && cached.expiresAt > now) return cached.value; - const baseUrl = - params.credential.type === "api_key" ? resolveGitHubApiBaseUrl(params) : resolveCopilotApiBaseUrl(params); + const githubApiBaseUrl = resolveGitHubApiBaseUrl(params); let report: UsageReport | null = null; if (params.credential.type === "api_key") { - let username = + let username: string | undefined; + const candidate = params.credential.accountId || params.credential.metadata?.username || params.credential.metadata?.user; - if ((!username || typeof username !== "string" || !username.trim()) && params.credential.apiKey) { - username = await resolveGitHubUsername(ctx, baseUrl, params.credential.apiKey, params.signal); + if (typeof candidate === "string" && candidate.trim()) { + username = candidate.trim(); } - if (typeof username !== "string" || !username.trim()) { + if (!username && params.credential.apiKey) { + username = await resolveGitHubUsername(ctx, githubApiBaseUrl, params.credential.apiKey, params.signal); + } + if (!username) { ctx.logger?.warn("Copilot usage requires username for billing API", { provider: params.provider }); } else if (params.credential.apiKey) { try { - const billing = await fetchBillingUsage(ctx, baseUrl, username, params.credential.apiKey, params.signal); + const billing = await fetchBillingUsage( + ctx, + githubApiBaseUrl, + username, + params.credential.apiKey, + params.signal, + ); report = { provider: "github-copilot", fetchedAt: now, limits: normalizeBillingUsage(billing), metadata: { + accountId: billing.user, account: billing.user, period: billing.timePeriod, }, @@ -465,26 +419,49 @@ export const githubCopilotUsageProvider: UsageProvider = { ctx.logger?.warn("Copilot usage fetch failed", { error: String(error) }); } } + if (!report && params.credential.apiKey) { + try { + const usage = await fetchInternalUsage(ctx, githubApiBaseUrl, params.credential.apiKey, params.signal); + const normalized = normalizeQuotaSnapshots(usage, now, username); + report = { + provider: "github-copilot", + fetchedAt: now, + limits: normalized.limits, + metadata: { + accountId: username, + plan: usage.copilot_plan, + quotaResetDate: usage.quota_reset_date, + }, + raw: usage, + }; + } catch (error) { + ctx.logger?.warn("Copilot usage fetch failed", { error: String(error) }); + } + } } else { - const { refreshToken, accessToken, expiresAt } = params.credential; + const { refreshToken, accessToken } = params.credential; if (!refreshToken && !accessToken) return null; const oauthToken = refreshToken || accessToken; if (!oauthToken) return null; + const githubToken = refreshToken ?? accessToken; + if (!githubToken) return null; try { - const usage = await fetchInternalUsage( - ctx, - baseUrl, - oauthToken, - accessToken ?? undefined, - expiresAt ?? undefined, - params.signal, - ); - const normalized = normalizeQuotaSnapshots(usage, now); + const usage = await fetchInternalUsage(ctx, githubApiBaseUrl, githubToken, params.signal); + let accountId = params.credential.accountId; + if (!accountId && refreshToken) { + accountId = await resolveGitHubUsername(ctx, githubApiBaseUrl, refreshToken, params.signal); + } + if (!accountId && accessToken) { + accountId = await resolveGitHubUsername(ctx, githubApiBaseUrl, accessToken, params.signal); + } + const normalized = normalizeQuotaSnapshots(usage, now, accountId); report = { provider: "github-copilot", fetchedAt: now, limits: normalized.limits, metadata: { + accountId, + email: params.credential.email, plan: usage.copilot_plan, quotaResetDate: usage.quota_reset_date, }, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1b751ae50..9ea66fd1a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,15 @@ # Changelog ## [Unreleased] - ### Added +- Added usage report deduplication to prevent duplicate account entries +- Added debug logging for usage fetch operations to aid diagnostics +- Added provider sorting in usage display by total usage amount +- Added `isolated` parameter to task tool for running each task in separate git worktrees +- Added git worktree management for isolated task execution with patch generation +- Added patch application system that applies changes only when all patches are valid +- Added working directory information to environment info display - Added `/usage` command to display provider usage and limits - Added support for multiple usage providers beyond Codex - Added usage report caching with configurable TTL @@ -14,9 +20,16 @@ - Added support for jq-like queries when reading JSON outputs - Added offset and limit parameters for reading specific line ranges from outputs - Added "." and "c" shortcuts to continue agent without sending visible message +- Added debug logging for usage fetch results to aid /usage diagnostics ### Changed +- Updated discoverSkills function to return object with skills property +- Enhanced usage report merging to combine limits and metadata from duplicate accounts +- Improved OAuth credential handling to preserve existing fields when updating +- Removed cd function from Python prelude to encourage using cwd parameter +- Updated task tool to generate and apply patches when running in isolated mode +- Enhanced task tool rendering to display isolated execution status and patch paths - Updated system prompt structure and formatting for better readability - Reorganized tool hierarchy and discipline sections - Added parallel work guidance for task-based workflows @@ -32,12 +45,17 @@ ### Fixed +- Fixed TypeScript error in bash executor by properly typing caught exception +- Fixed usage display ordering to show providers with lowest usage first +- Fixed task tool result rendering to show fallback text when no results are available - Fixed external editor to work properly on Unix systems by correctly handling terminal I/O - Fixed external editor to show warning message when it fails to open instead of silently failing - Fixed find tool to properly handle no matches case without treating as error - Fixed find tool to wait for fd exit so error messages no longer report exit null - Fixed read tool to properly handle no matches case without treating as error - Fixed orphaned Python kernel gateway processes not being killed on process exit +- Fixed /usage provider ordering to sort by aggregate usage (most used last) +- Fixed /usage account dedupe to collapse identical accounts using usage metadata ## [6.9.69] - 2026-01-21 @@ -81,6 +99,7 @@ - Fixed patch indentation normalization for fuzzy matches, tab/space diffs, and ambiguous context alignment ## [6.9.0] - 2026-01-21 + ### Removed - Removed Git tool and all related functionality @@ -91,6 +110,7 @@ - Removed @oh-my-pi/pi-git-tool dependency ## [6.8.5] - 2026-01-21 + ### Breaking Changes - Changed timeout parameter from seconds to milliseconds in Python tool @@ -102,6 +122,7 @@ - Improved streaming output handling and buffer management ## [6.8.4] - 2026-01-21 + ### Changed - Updated output sink to properly handle large outputs @@ -183,6 +204,7 @@ - Updated temporary file cleanup to use secure async removal methods ## [6.7.67] - 2026-01-19 + ### Added - Added normative rewrite setting to control tool call argument normalization in session history @@ -231,7 +253,7 @@ - Patch application handles repeated context blocks, preserves original indentation on fuzzy match - Ambiguous context matching resolves duplicates using adjacent @@ anchor positioning -- Patch parser handles bare *** terminators, model hallucination markers, line hint ranges +- Patch parser handles bare \*\*\* terminators, model hallucination markers, line hint ranges - Function context matching handles signatures with and without empty parentheses - Fixed session title generation to respect OMP_NO_TITLE environment variable - Fixed Python module discovery to use import.meta.dir for ES module compatibility @@ -243,6 +265,7 @@ - Fixed MCP tool path formatting to correctly display provider information ## [6.2.0] - 2026-01-19 + ### Changed - Improved LSP batching to coalesce formatting and diagnostics for parallel edits @@ -280,12 +303,14 @@ - Fixed TTSR abbreviation expansion from TTSR to Time Traveling Stream Rules ## [5.8.0] - 2026-01-19 + ### Changed - Updated WASM loading to use streaming for development environments with base64 fallback - Added scripts directory to published package files ## [5.7.68] - 2026-01-18 + ### Changed - Updated WASM loading to use base64-encoded WASM for better compatibility with compiled binaries @@ -295,12 +320,14 @@ - Fixed WASM loading issues in compiled binary builds ## [5.7.67] - 2026-01-18 + ### Changed - Replaced external photon-node dependency with vendored WebAssembly implementation - Updated image processing to use local photon library for better performance ## [5.6.70] - 2026-01-18 + ### Added - Added support for loading Python prelude extension modules from user and project directories @@ -373,7 +400,7 @@ - Enhanced Python kernel availability checking with faster validation - Optimized Python environment warming to avoid blocking during tool initialization - Reorganized settings interface into behavior, tools, display, voice, status, lsp, and exa tabs -- Migrated environment variables from PI_ to OMP_ prefix with automatic migration +- Migrated environment variables from PI* to OMP* prefix with automatic migration - Updated model selector to use TabBar component for provider navigation - Changed role badges to inverted style with colored backgrounds - Added support for /models command alias in addition to /model @@ -406,11 +433,13 @@ - Enhanced Python gateway environment filtering to exclude sensitive API keys and Windows system paths ## [5.5.0] - 2026-01-18 + ### Changed - Updated task execution guidelines to improve prompt framing and parallelization instructions ## [5.4.2] - 2026-01-16 + ### Changed - Updated model resolution to accept pre-serialized settings for better performance @@ -418,11 +447,13 @@ - Enhanced edit tool documentation with clear use cases for bash alternatives ## [5.3.0] - 2026-01-15 + ### Changed - Expanded bash tool guidance to explicitly list appropriate use cases including file operations, build commands, and process management ## [5.2.1] - 2026-01-14 + ### Fixed - Fixed stale diagnostic results by tracking diagnostic versions before file sync operations @@ -466,6 +497,7 @@ - Fixed session selector page up/down navigation ## [5.0.1] - 2026-01-12 + ### Changed - Replaced wasm-vips with Photon for more stable WASM image processing @@ -492,6 +524,7 @@ - Move `sharp` to optional dependencies with all platform binaries to fix arm64 runtime errors ## [4.7.0] - 2026-01-12 + ### Added - Add `omp config` subcommand for managing settings (`list`, `get`, `set`, `reset`, `path`) @@ -511,6 +544,7 @@ ## [4.6.0] - 2026-01-12 ### Added + - Add `/skill:name` slash commands for quick skill access (toggle via `skills.enableSkillCommands` setting) - Add `cwd` to SessionInfo for session list display - Add custom summarization instructions option in tree selector @@ -518,10 +552,12 @@ - Add `shutdownRequested` and `checkShutdownRequested()` for extension-initiated shutdown ### Fixed + - Component `invalidate()` now properly rebuilds content on theme changes - Force full re-render after returning from external editor ## [4.4.8] - 2026-01-12 + ### Changed - Changed review finding priority format from numeric (0-3) to string labels (P0-P3) for clearer severity indication @@ -541,6 +577,7 @@ - Fixed frontmatter parsing to properly report source location when YAML parsing fails ## [4.4.4] - 2026-01-11 + ### Added - Added `todo_write` tool for creating and managing structured task lists during coding sessions @@ -571,6 +608,7 @@ - Fixed prompt template loading to strip leading HTML comment metadata blocks ## [4.3.2] - 2026-01-11 + ### Changed - Increased default bash output preview from 5 to 10 lines when collapsed @@ -610,6 +648,7 @@ - Fixed serialized auth storage initialization so OAuth refreshes in subagents don't crash ## [4.2.2] - 2026-01-11 + ### Added - Added persistent cache storage for Codex usage data that survives application restarts @@ -630,6 +669,7 @@ - Removed `planner` agent command template, consolidating planning functionality into the `plan` agent ## [4.2.1] - 2026-01-11 + ### Added - Added automatic discovery and listing of AGENTS.md files in the system prompt, providing agents with an authoritative list of project-specific instruction files without runtime searching @@ -696,6 +736,7 @@ - Hardened file permissions on agent database directory (700) and database file (600) to restrict access ## [4.1.0] - 2026-01-10 + ### Added - Added persistent prompt history with SQLite-backed storage and Ctrl+R search @@ -705,6 +746,7 @@ - Fixed credential blocking logic to correctly check for remaining available credentials instead of always returning true ## [4.0.1] - 2026-01-10 + ### Added - Added usage limit error detection to enable automatic credential switching when Codex accounts hit rate limits @@ -808,11 +850,13 @@ - Extension directories in `settings.json` respect `package.json` manifests ## [3.37.0] - 2026-01-10 + ### Changed - Improved bash command display to show relative paths for working directories within the current directory, and hide redundant `cd` prefix when working directory matches current directory ## [3.36.0] - 2026-01-10 + ### Added - Added `calc` tool for basic mathematical calculations with support for arithmetic operators, parentheses, and hex/binary/octal literals @@ -831,6 +875,7 @@ - Improved completion notification message to include session title when available ## [3.35.0] - 2026-01-09 + ### Added - Added retry logic with exponential backoff for auto-compaction failures @@ -1908,15 +1953,14 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo - **Credential storage refactored**: API keys and OAuth tokens are now stored in `~/.omp/agent/auth.json` instead of `oauth.json` and `settings.json`. Existing credentials are automatically migrated on first run. ([#296](https://github.com/badlogic/pi-mono/issues/296)) - **SDK API changes** ([#296](https://github.com/badlogic/pi-mono/issues/296)): - - - Added `AuthStorage` class for credential management (API keys and OAuth tokens) - - Added `ModelRegistry` class for model discovery and API key resolution - - Added `discoverAuthStorage()` and `discoverModels()` discovery functions - - `createAgentSession()` now accepts `authStorage` and `modelRegistry` options - - Removed `configureOAuthStorage()`, `defaultGetApiKey()`, `findModel()`, `discoverAvailableModels()` - - Removed `getApiKey` callback option (use `AuthStorage.setRuntimeApiKey()` for runtime overrides) - - Use `getModel()` from `@oh-my-pi/pi-ai` for built-in models, `modelRegistry.find()` for custom models + built-in models - - See updated [SDK documentation](docs/sdk.md) and [README](README.md) + - Added `AuthStorage` class for credential management (API keys and OAuth tokens) + - Added `ModelRegistry` class for model discovery and API key resolution + - Added `discoverAuthStorage()` and `discoverModels()` discovery functions + - `createAgentSession()` now accepts `authStorage` and `modelRegistry` options + - Removed `configureOAuthStorage()`, `defaultGetApiKey()`, `findModel()`, `discoverAvailableModels()` + - Removed `getApiKey` callback option (use `AuthStorage.setRuntimeApiKey()` for runtime overrides) + - Use `getModel()` from `@oh-my-pi/pi-ai` for built-in models, `modelRegistry.find()` for custom models + built-in models + - See updated [SDK documentation](docs/sdk.md) and [README](README.md) - **Settings changes**: Removed `apiKeys` from `settings.json`. Use `auth.json` instead. ([#296](https://github.com/badlogic/pi-mono/issues/296)) @@ -1947,16 +1991,15 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Added - **Compaction hook improvements**: The `before_compact` session event now includes: - - - `previousSummary`: Summary from the last compaction (if any), so hooks can preserve accumulated context - - `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize` - - `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars) - - Removed `apiKey` string in favor of `resolveApiKey` for more flexibility + - `previousSummary`: Summary from the last compaction (if any), so hooks can preserve accumulated context + - `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize` + - `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars) + - Removed `apiKey` string in favor of `resolveApiKey` for more flexibility - **SessionManager API cleanup**: - - Renamed `loadSessionFromEntries()` to `buildSessionContext()` (builds LLM context from entries, handling compaction) - - Renamed `loadEntries()` to `getEntries()` (returns defensive copy of all session entries) - - Added `buildSessionContext()` method to SessionManager + - Renamed `loadSessionFromEntries()` to `buildSessionContext()` (builds LLM context from entries, handling compaction) + - Renamed `loadEntries()` to `getEntries()` (returns defensive copy of all session entries) + - Added `buildSessionContext()` method to SessionManager ## [0.27.5] - 2025-12-24 @@ -2175,13 +2218,13 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo - **Custom tools now require `index.ts` entry point**: Auto-discovered custom tools must be in a subdirectory with an `index.ts` file. The old pattern `~/.omp/agent/tools/mytool.ts` must become `~/.omp/agent/tools/mytool/index.ts`. This allows multi-file tools to import helper modules. Explicit paths via `--tool` or `settings.json` still work with any `.ts` file. - **Hook `tool_result` event restructured**: The `ToolResultEvent` now exposes full tool result data instead of just text. ([#233](https://github.com/badlogic/pi-mono/pull/233)) - - Removed: `result: string` field - - Added: `content: (TextContent | ImageContent)[]` - full content array - - Added: `details: unknown` - tool-specific details (typed per tool via discriminated union on `toolName`) - - `ToolResultEventResult.result` renamed to `ToolResultEventResult.text` (removed), use `content` instead - - Hook handlers returning `{ result: "..." }` must change to `{ content: [{ type: "text", text: "..." }] }` - - Built-in tool details types exported: `BashToolDetails`, `ReadToolDetails`, `GrepToolDetails`, `FindToolDetails`, `LsToolDetails`, `TruncationResult` - - Type guards exported for narrowing: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult` + - Removed: `result: string` field + - Added: `content: (TextContent | ImageContent)[]` - full content array + - Added: `details: unknown` - tool-specific details (typed per tool via discriminated union on `toolName`) + - `ToolResultEventResult.result` renamed to `ToolResultEventResult.text` (removed), use `content` instead + - Hook handlers returning `{ result: "..." }` must change to `{ content: [{ type: "text", text: "..." }] }` + - Built-in tool details types exported: `BashToolDetails`, `ReadToolDetails`, `GrepToolDetails`, `FindToolDetails`, `LsToolDetails`, `TruncationResult` + - Type guards exported for narrowing: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult` ## [0.23.4] - 2025-12-18 @@ -2210,13 +2253,12 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo - Improved system prompt documentation section with clearer pointers to specific doc files for custom models, themes, skills, hooks, custom tools, and RPC. - Cleaned up documentation: - - - `theme.md`: Added missing color tokens (`thinkingXhigh`, `bashMode`) - - `skills.md`: Rewrote with better framing and examples - - `hooks.md`: Fixed timeout/error handling docs, added import aliases section - - `custom-tools.md`: Added intro with use cases and comparison table - - `rpc.md`: Added missing `hook_error` event documentation - - `README.md`: Complete settings table, condensed philosophy section, standardized OAuth docs + - `theme.md`: Added missing color tokens (`thinkingXhigh`, `bashMode`) + - `skills.md`: Rewrote with better framing and examples + - `hooks.md`: Fixed timeout/error handling docs, added import aliases section + - `custom-tools.md`: Added intro with use cases and comparison table + - `rpc.md`: Added missing `hook_error` event documentation + - `README.md`: Complete settings table, condensed philosophy section, standardized OAuth docs - Hooks loader now supports same import aliases as custom tools (`@sinclair/typebox`, `@oh-my-pi/pi-ai`, `@oh-my-pi/pi-tui`, `@oh-my-pi/pi-coding-agent`). @@ -2473,12 +2515,12 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ### Changed - **Tool output truncation**: All tools now enforce consistent truncation limits with actionable notices for the LLM. ([#134](https://github.com/badlogic/pi-mono/issues/134)) - - **Limits**: 2000 lines OR 50KB (whichever hits first), never partial lines - - **read**: Shows `[Showing lines X-Y of Z. Use offset=N to continue]`. If first line exceeds 50KB, suggests bash command - - **bash**: Tail truncation with temp file. Shows `[Showing lines X-Y of Z. Full output: /tmp/...]` - - **grep**: Pre-truncates match lines to 500 chars. Shows match limit and line truncation notices - - **find/ls**: Shows result/entry limit notices - - TUI displays truncation warnings in yellow at bottom of tool output (visible even when collapsed) + - **Limits**: 2000 lines OR 50KB (whichever hits first), never partial lines + - **read**: Shows `[Showing lines X-Y of Z. Use offset=N to continue]`. If first line exceeds 50KB, suggests bash command + - **bash**: Tail truncation with temp file. Shows `[Showing lines X-Y of Z. Full output: /tmp/...]` + - **grep**: Pre-truncates match lines to 500 chars. Shows match limit and line truncation notices + - **find/ls**: Shows result/entry limit notices + - TUI displays truncation warnings in yellow at bottom of tool output (visible even when collapsed) ## [0.13.1] - 2025-12-06 @@ -2565,13 +2607,13 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ### Added - **Context Compaction**: Long sessions can now be compacted to reduce context usage while preserving recent conversation history. ([#92](https://github.com/badlogic/pi-mono/issues/92), [docs](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md#context-compaction)) - - `/compact [instructions]`: Manually compact context with optional custom instructions for the summary - - `/autocompact`: Toggle automatic compaction when context exceeds threshold - - Compaction summarizes older messages while keeping recent messages (default 20k tokens) verbatim - - Auto-compaction triggers when context reaches `contextWindow - reserveTokens` (default 16k reserve) - - Compacted sessions show a collapsible summary in the TUI (toggle with `o` key) - - HTML exports include compaction summaries as collapsible sections - - RPC mode supports `{"type":"compact"}` command and auto-compaction (emits compaction events) + - `/compact [instructions]`: Manually compact context with optional custom instructions for the summary + - `/autocompact`: Toggle automatic compaction when context exceeds threshold + - Compaction summarizes older messages while keeping recent messages (default 20k tokens) verbatim + - Auto-compaction triggers when context reaches `contextWindow - reserveTokens` (default 16k reserve) + - Compacted sessions show a collapsible summary in the TUI (toggle with `o` key) + - HTML exports include compaction summaries as collapsible sections + - RPC mode supports `{"type":"compact"}` command and auto-compaction (emits compaction events) - **Branch Source Tracking**: Branched sessions now store `branchedFrom` in the session header, containing the path to the original session file. Useful for tracing session lineage. ## [0.12.5] - 2025-12-03 @@ -2608,11 +2650,11 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ### Added - **Models**: Added support for OpenAI's new models: - - `gpt-4.1` (128K context) - - `gpt-4.1-mini` (128K context) - - `gpt-4.1-nano` (128K context) - - `o3` (200K context, reasoning model) - - `o4-mini` (200K context, reasoning model) + - `gpt-4.1` (128K context) + - `gpt-4.1-mini` (128K context) + - `gpt-4.1-nano` (128K context) + - `o3` (200K context, reasoning model) + - `o4-mini` (200K context, reasoning model) ## [0.12.0] - 2025-12-02 diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts index 55f5f2793..4e38299e1 100644 --- a/packages/coding-agent/examples/sdk/04-skills.ts +++ b/packages/coding-agent/examples/sdk/04-skills.ts @@ -8,7 +8,7 @@ import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@oh-my-pi/pi-coding-agent"; // Discover all skills from cwd/.omp/skills, ~/.omp/agent/skills, etc. -const allSkills = discoverSkills(); +const { skills: allSkills } = await discoverSkills(); console.log( "Discovered skills:", allSkills.map((s) => s.name), diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index c8f85ec98..70aab8b82 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -801,6 +801,112 @@ export class AuthStorage { }; } + private getUsageReportMetadataValue(report: UsageReport, key: string): string | undefined { + const metadata = report.metadata; + if (!metadata || typeof metadata !== "object") return undefined; + const value = metadata[key]; + return typeof value === "string" ? value.trim() : undefined; + } + + private getUsageReportScopeAccountId(report: UsageReport): string | undefined { + const ids = new Set(); + for (const limit of report.limits) { + const accountId = limit.scope.accountId?.trim(); + if (accountId) ids.add(accountId); + } + if (ids.size === 1) return [...ids][0]; + return undefined; + } + + private getUsageReportIdentifiers(report: UsageReport): string[] { + const identifiers: string[] = []; + const email = this.getUsageReportMetadataValue(report, "email"); + if (email) identifiers.push(`email:${email.toLowerCase()}`); + const accountId = this.getUsageReportMetadataValue(report, "accountId"); + if (accountId) identifiers.push(`account:${accountId}`); + const account = this.getUsageReportMetadataValue(report, "account"); + if (account) identifiers.push(`account:${account}`); + const user = this.getUsageReportMetadataValue(report, "user"); + if (user) identifiers.push(`account:${user}`); + const username = this.getUsageReportMetadataValue(report, "username"); + if (username) identifiers.push(`account:${username}`); + const scopeAccountId = this.getUsageReportScopeAccountId(report); + if (scopeAccountId) identifiers.push(`account:${scopeAccountId}`); + return identifiers.map((identifier) => `${report.provider}:${identifier.toLowerCase()}`); + } + + private mergeUsageReportGroup(reports: UsageReport[]): UsageReport { + if (reports.length === 1) return reports[0]; + const sorted = [...reports].sort((a, b) => { + const limitDiff = b.limits.length - a.limits.length; + if (limitDiff !== 0) return limitDiff; + return (b.fetchedAt ?? 0) - (a.fetchedAt ?? 0); + }); + const base = sorted[0]; + const mergedLimits = [...base.limits]; + const limitIds = new Set(mergedLimits.map((limit) => limit.id)); + const mergedMetadata: Record = { ...(base.metadata ?? {}) }; + let fetchedAt = base.fetchedAt; + + for (const report of sorted.slice(1)) { + fetchedAt = Math.max(fetchedAt, report.fetchedAt); + for (const limit of report.limits) { + if (!limitIds.has(limit.id)) { + limitIds.add(limit.id); + mergedLimits.push(limit); + } + } + if (report.metadata) { + for (const [key, value] of Object.entries(report.metadata)) { + if (mergedMetadata[key] === undefined) { + mergedMetadata[key] = value; + } + } + } + } + + return { + ...base, + fetchedAt, + limits: mergedLimits, + metadata: Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined, + }; + } + + private dedupeUsageReports(reports: UsageReport[]): UsageReport[] { + const groups: UsageReport[][] = []; + const idToGroup = new Map(); + + for (const report of reports) { + const identifiers = this.getUsageReportIdentifiers(report); + let groupIndex: number | undefined; + for (const identifier of identifiers) { + const existing = idToGroup.get(identifier); + if (existing !== undefined) { + groupIndex = existing; + break; + } + } + if (groupIndex === undefined) { + groupIndex = groups.length; + groups.push([]); + } + groups[groupIndex].push(report); + for (const identifier of identifiers) { + idToGroup.set(identifier, groupIndex); + } + } + + const deduped = groups.map((group) => this.mergeUsageReportGroup(group)); + if (deduped.length !== reports.length) { + this.usageLogger?.debug("Usage reports deduped", { + before: reports.length, + after: deduped.length, + }); + } + return deduped; + } + private isUsageLimitExhausted(limit: UsageLimit): boolean { if (limit.status === "exhausted") return true; const amount = limit.amount; @@ -883,6 +989,9 @@ export class AuthStorage { ...this.data.keys(), ...DEFAULT_USAGE_PROVIDERS.map((provider) => provider.id), ]); + this.usageLogger?.debug("Usage fetch requested", { + providers: Array.from(providers).sort(), + }); for (const provider of providers) { const providerImpl = resolver(provider as Provider); if (!providerImpl) continue; @@ -911,6 +1020,11 @@ export class AuthStorage { if (providerImpl.supports && !providerImpl.supports(params)) { continue; } + this.usageLogger?.debug("Usage fetch queued", { + provider, + credentialType: "api_key", + baseUrl, + }); tasks.push( providerImpl .fetchUsage(params, { @@ -946,6 +1060,14 @@ export class AuthStorage { continue; } + this.usageLogger?.debug("Usage fetch queued", { + provider, + credentialType: usageCredential.type, + baseUrl, + accountId: usageCredential.accountId, + email: usageCredential.email, + }); + tasks.push( providerImpl .fetchUsage(params, { @@ -967,7 +1089,25 @@ export class AuthStorage { if (tasks.length === 0) return []; const results = await Promise.all(tasks); - return results.filter((report): report is UsageReport => report !== null); + const reports = results.filter((report): report is UsageReport => report !== null); + const deduped = this.dedupeUsageReports(reports); + this.usageLogger?.debug("Usage fetch resolved", { + reports: deduped.map((report) => { + const accountLabel = + this.getUsageReportMetadataValue(report, "email") ?? + this.getUsageReportMetadataValue(report, "accountId") ?? + this.getUsageReportMetadataValue(report, "account") ?? + this.getUsageReportMetadataValue(report, "user") ?? + this.getUsageReportMetadataValue(report, "username") ?? + this.getUsageReportScopeAccountId(report); + return { + provider: report.provider, + limits: report.limits.length, + account: accountLabel, + }; + }), + }); + return deduped; } /** @@ -1093,7 +1233,16 @@ export class AuthStorage { const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds); if (!result) return undefined; - const updated: OAuthCredential = { type: "oauth", ...result.newCredentials }; + const updated: OAuthCredential = { + type: "oauth", + access: result.newCredentials.access, + refresh: result.newCredentials.refresh, + expires: result.newCredentials.expires, + accountId: result.newCredentials.accountId ?? selection.credential.accountId, + email: result.newCredentials.email ?? selection.credential.email, + projectId: result.newCredentials.projectId ?? selection.credential.projectId, + enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl, + }; this.replaceCredentialAt(provider, selection.index, updated); if (checkUsage) { diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index f93cefc88..567e0e2d4 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -56,7 +56,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions cancelled: false, ...(await sink.dump()), }; - } catch (err) { + } catch (err: unknown) { // Exception covers NonZeroExitError, AbortError, TimeoutError if (err instanceof Exception) { if (err.aborted) { diff --git a/packages/coding-agent/src/core/python-prelude.py b/packages/coding-agent/src/core/python-prelude.py index f74ee2365..9d7155ceb 100644 --- a/packages/coding-agent/src/core/python-prelude.py +++ b/packages/coding-agent/src/core/python-prelude.py @@ -24,14 +24,6 @@ if "__omp_prelude_loaded__" not in globals(): _emit_status("pwd", path=str(p)) return p - @_category("Navigation") - def cd(path: str | Path) -> Path: - """Change directory.""" - p = Path(path).expanduser().resolve() - os.chdir(p) - _emit_status("cd", path=str(p)) - return p - @_category("Shell") def env(key: str | None = None, value: str | None = None): """Get/set environment variables.""" diff --git a/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts index 10e9d3fc0..f8dca6c1b 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts @@ -684,6 +684,13 @@ function resolveFraction(limit: UsageLimit): number | undefined { return undefined; } +function resolveProviderUsageTotal(reports: UsageReport[]): number { + return reports + .flatMap((report) => report.limits) + .map((limit) => resolveFraction(limit) ?? 0) + .reduce((sum, value) => sum + value, 0); +} + function formatLimitTitle(limit: UsageLimit): string { const tier = limit.scope.tier; if (tier && !limit.label.toLowerCase().includes(tier.toLowerCase())) { @@ -841,8 +848,18 @@ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs list.push(report); grouped.set(report.provider, list); } + const providerEntries = Array.from(grouped.entries()) + .map(([provider, providerReports]) => ({ + provider, + providerReports, + totalUsage: resolveProviderUsageTotal(providerReports), + })) + .sort((a, b) => { + if (a.totalUsage !== b.totalUsage) return a.totalUsage - b.totalUsage; + return a.provider.localeCompare(b.provider); + }); - for (const [provider, providerReports] of grouped.entries()) { + for (const { provider, providerReports } of providerEntries) { lines.push(""); const providerName = formatProviderName(provider); diff --git a/packages/coding-agent/src/prompts/tools/python.md b/packages/coding-agent/src/prompts/tools/python.md index 555067059..6bfdb021a 100644 --- a/packages/coding-agent/src/prompts/tools/python.md +++ b/packages/coding-agent/src/prompts/tools/python.md @@ -113,7 +113,6 @@ cols(read("data.tsv"), 0, 2, sep="\t") - Code executes as IPython cells; users see the full cell output (including rendered figures, tables, etc.) - Kernel persists for the session by default; per-call mode uses a fresh kernel each call. Use `reset: true` to clear state when session mode is active -- Use `cwd` parameter instead of `os.chdir()` in tool call - Use `plt.show()` to display figures - Use `display()` from IPython.display for rich output (HTML, Markdown, images, etc.) - Output streams in real time, truncated after 50KB diff --git a/scripts/sync-themes.ts b/scripts/sync-themes.ts index 0251ea2dd..7c5fbf400 100644 --- a/scripts/sync-themes.ts +++ b/scripts/sync-themes.ts @@ -11,27 +11,27 @@ const THEMES_DIR = join(process.cwd(), "packages/coding-agent/src/modes/interact const INDEX_FILE = join(THEMES_DIR, "index.ts"); async function main() { - const files = await readdir(THEMES_DIR); - const jsonFiles = files.filter(f => f.endsWith(".json")).sort(); + const files = await readdir(THEMES_DIR); + const jsonFiles = files.filter((f) => f.endsWith(".json")).sort(); - const imports: string[] = []; - const exportEntries: string[] = []; + const imports: string[] = []; + const exportEntries: string[] = []; - for (const file of jsonFiles) { - const name = file.replace(".json", ""); - const varName = name.replace(/-/g, "_"); - - imports.push(`import ${varName} from "./${file}" with { type: "json" };`); - exportEntries.push(` "${name}": ${varName},`); - } + for (const file of jsonFiles) { + const name = file.replace(".json", ""); + const varName = name.replace(/-/g, "_"); - let content = imports.join("\n"); - content += "\n\nexport const defaultThemes = {\n"; - content += exportEntries.join("\n"); - content += "\n};\n"; + imports.push(`import ${varName} from "./${file}" with { type: "json" };`); + exportEntries.push(` "${name}": ${varName},`); + } - await Bun.write(INDEX_FILE, content); - console.log(`Updated ${INDEX_FILE} with ${jsonFiles.length} themes.`); + let content = imports.join("\n"); + content += "\n\nexport const defaultThemes = {\n"; + content += exportEntries.join("\n"); + content += "\n};\n"; + + await Bun.write(INDEX_FILE, content); + console.log(`Updated ${INDEX_FILE} with ${jsonFiles.length} themes.`); } main().catch(console.error);